phlex_kit 0.10.0 → 0.11.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_controller.js +3 -0
- data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -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/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 +33 -9
- data/app/components/phlex_kit/carousel/carousel.rb +3 -2
- data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
- data/app/components/phlex_kit/chart/chart.rb +8 -2
- 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 +7 -1
- data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +15 -1
- 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 +6 -1
- 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_controller.js +25 -0
- 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 +0 -10
- 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/date_picker/date_picker.rb +6 -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 +13 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
- 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 +22 -1
- data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
- data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
- 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_group/input_group.css +0 -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_controller.js +6 -0
- 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.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -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 +23 -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 -2
- data/app/components/phlex_kit/select/select.css +5 -2
- data/app/components/phlex_kit/select/select_controller.js +27 -4
- 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_controller.js +4 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
- 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/switch/switch.css +5 -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_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_controller.js +7 -4
- data/app/components/phlex_kit/toast/toast_region.rb +19 -9
- data/app/components/phlex_kit/toast/toaster_controller.js +15 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +5 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
- 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/lib/phlex_kit/base_component.rb +13 -0
- data/lib/phlex_kit/version.rb +1 -1
- metadata +3 -1
|
@@ -36,6 +36,9 @@ export default class extends Controller {
|
|
|
36
36
|
const next = handle.nextElementSibling
|
|
37
37
|
if (!prev || !next) return
|
|
38
38
|
e.preventDefault()
|
|
39
|
+
// preventDefault suppresses mouse-driven focus — hand it to the handle
|
|
40
|
+
// so a drag can be fine-tuned with the arrow keys immediately.
|
|
41
|
+
handle.focus({ preventScroll: true })
|
|
39
42
|
|
|
40
43
|
const horizontal = this.directionValue === "horizontal"
|
|
41
44
|
const sizeOf = (el) => horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height
|
|
@@ -12,8 +12,11 @@ module PhlexKit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def view_template(&)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
16
|
+
# fuse role="region log" / tabindex="0 -1" instead of overriding.
|
|
17
|
+
base = { class: "pk-scroll-area" }
|
|
18
|
+
base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
19
|
+
base[:role] = "region" if labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
|
|
17
20
|
div(**mix(base, @attrs), &)
|
|
18
21
|
end
|
|
19
22
|
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
border-radius: var(--pk-radius);
|
|
24
24
|
border: 1px solid var(--pk-input);
|
|
25
25
|
color: var(--pk-text);
|
|
26
|
-
padding: .25rem
|
|
26
|
+
padding-block: .25rem;
|
|
27
|
+
padding-inline: .625rem .5rem;
|
|
27
28
|
gap: .375rem;
|
|
28
29
|
font: inherit;
|
|
29
30
|
font-size: .875rem;
|
|
@@ -87,7 +88,9 @@
|
|
|
87
88
|
user-select: none;
|
|
88
89
|
gap: .375rem;
|
|
89
90
|
border-radius: calc(var(--pk-radius) - 2px);
|
|
90
|
-
|
|
91
|
+
/* 2rem gutter on the inline-end — where .pk-select-item-check sits. */
|
|
92
|
+
padding-block: .25rem;
|
|
93
|
+
padding-inline: .375rem 2rem;
|
|
91
94
|
font-size: .875rem;
|
|
92
95
|
color: var(--pk-text);
|
|
93
96
|
outline: none;
|
|
@@ -9,7 +9,28 @@ export default class extends Controller {
|
|
|
9
9
|
static targets = ["trigger", "content", "input", "value", "item"];
|
|
10
10
|
static values = { open: Boolean };
|
|
11
11
|
|
|
12
|
+
initialize() {
|
|
13
|
+
// Stimulus fires [target]Connected before connect(): state used by
|
|
14
|
+
// itemTargetConnected must be initialized here.
|
|
15
|
+
this.itemIdCounter = 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Per-item, not a connect()-time sweep: options injected later (Turbo
|
|
19
|
+
// stream/frame) must still get ids or aria-activedescendant would point
|
|
20
|
+
// at nothing — and a caller-provided id is left alone, not clobbered.
|
|
21
|
+
itemTargetConnected(item) {
|
|
22
|
+
if (!item.id) item.id = `${this.contentTarget.id}-${this.itemIdCounter++}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
connect() {
|
|
26
|
+
// A Turbo snapshot serializes aria-expanded / aria-activedescendant /
|
|
27
|
+
// aria-current even though :popover-open does not survive the restore —
|
|
28
|
+
// normalize here so a restored page doesn't announce an open listbox
|
|
29
|
+
// over a closed select (same mitigation as dropdown_menu's connect).
|
|
30
|
+
this.openValue = false;
|
|
31
|
+
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
32
|
+
this.triggerTarget.removeAttribute("aria-activedescendant");
|
|
33
|
+
this.itemTargets.forEach((item) => item.removeAttribute("aria-current"));
|
|
13
34
|
this.generateItemsIds();
|
|
14
35
|
}
|
|
15
36
|
|
|
@@ -176,10 +197,12 @@ export default class extends Controller {
|
|
|
176
197
|
generateItemsIds() {
|
|
177
198
|
const contentId = this.contentTarget.getAttribute("id");
|
|
178
199
|
this.triggerTarget.setAttribute("aria-controls", contentId);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
200
|
+
// Name the listbox from the trigger (Radix wires it the same way) —
|
|
201
|
+
// an unnamed role=listbox is announced as an anonymous list.
|
|
202
|
+
if (!this.triggerTarget.id) this.triggerTarget.id = `${contentId}-trigger`;
|
|
203
|
+
if (!this.contentTarget.hasAttribute("aria-labelledby")) {
|
|
204
|
+
this.contentTarget.setAttribute("aria-labelledby", this.triggerTarget.id);
|
|
205
|
+
}
|
|
183
206
|
}
|
|
184
207
|
|
|
185
208
|
setAriaCurrentAndActiveDescendant(currentIndex) {
|
|
@@ -24,7 +24,11 @@ module PhlexKit
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def view_template(&block)
|
|
27
|
-
|
|
27
|
+
base = { role: (@decorative ? "none" : "separator"), class: classes }
|
|
28
|
+
# role="separator" is implicitly horizontal — a semantic vertical one
|
|
29
|
+
# must say so or AT announces the wrong axis.
|
|
30
|
+
base[:"aria-orientation"] = "vertical" if !@decorative && @orientation == :vertical
|
|
31
|
+
send(@as, **mix(base, @attrs), &block)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
private
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
}
|
|
17
17
|
.pk-sheet-content.right { inset-block: 0; right: 0; height: 100%; width: 75%; max-width: 24rem; border-left: 1px solid var(--pk-border); }
|
|
18
18
|
.pk-sheet-content.left { inset-block: 0; left: 0; height: 100%; width: 75%; max-width: 24rem; border-right: 1px solid var(--pk-border); }
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/* max-height: without it a tall top/bottom panel grows past the viewport and
|
|
20
|
+
overflow:auto never engages (footer/close unreachable). */
|
|
21
|
+
.pk-sheet-content.top { inset-inline: 0; top: 0; max-height: 100dvh; border-bottom: 1px solid var(--pk-border); }
|
|
22
|
+
.pk-sheet-content.bottom { inset-inline: 0; bottom: 0; max-height: 100dvh; border-top: 1px solid var(--pk-border); }
|
|
21
23
|
.pk-sheet-header { display: flex; flex-direction: column; gap: .125rem; padding: 1rem; }
|
|
22
24
|
.pk-sheet-footer { display: flex; flex-direction: column-reverse; gap: .5rem; padding: 1rem; margin-top: auto; }
|
|
23
25
|
.pk-sheet-title { font-size: 1rem; font-weight: 500; line-height: 1; color: var(--pk-text); }
|
|
@@ -29,6 +29,10 @@ export default class extends Controller {
|
|
|
29
29
|
this.#restoreInert()
|
|
30
30
|
document.body.style.overflow = this.previousOverflow
|
|
31
31
|
if (this.opener?.isConnected) this.opener.focus()
|
|
32
|
+
// Tell the source phlex-kit--sheet controller the clone is gone so it
|
|
33
|
+
// can flip the trigger's aria-expanded back (removal happens by many
|
|
34
|
+
// paths: close button, Escape, backdrop, before-cache).
|
|
35
|
+
document.dispatchEvent(new CustomEvent("phlex-kit:sheet-content:closed"))
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
close() {
|
|
@@ -19,17 +19,32 @@ export default class extends Controller {
|
|
|
19
19
|
|
|
20
20
|
connect() {
|
|
21
21
|
document.addEventListener("turbo:before-cache", this.clearOverlay)
|
|
22
|
+
// Popup semantics for AT: the trigger's real control announces that it
|
|
23
|
+
// opens a dialog, and expanded state tracks the clone's lifetime (the
|
|
24
|
+
// content controller signals removal via phlex-kit:sheet-content:closed).
|
|
25
|
+
this.invoker = this.element.querySelector(".pk-sheet-trigger button, .pk-sheet-trigger a, .pk-sheet-trigger [tabindex]")
|
|
26
|
+
this.invoker?.setAttribute("aria-haspopup", "dialog")
|
|
27
|
+
this.invoker?.setAttribute("aria-expanded", "false")
|
|
28
|
+
this.onOverlayClosed = () => {
|
|
29
|
+
if (!this.overlay?.isConnected) this.invoker?.setAttribute("aria-expanded", "false")
|
|
30
|
+
}
|
|
31
|
+
document.addEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
|
|
22
32
|
if (this.openValue) this.open()
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
disconnect() {
|
|
26
36
|
document.removeEventListener("turbo:before-cache", this.clearOverlay)
|
|
37
|
+
document.removeEventListener("phlex-kit:sheet-content:closed", this.onOverlayClosed)
|
|
27
38
|
this.clearOverlay()
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
open() {
|
|
31
42
|
if (this.overlay?.isConnected) return
|
|
43
|
+
// open: is one-shot — the reflected value sits in the Turbo snapshot, so
|
|
44
|
+
// a cache-restored reconnect would re-open a dismissed sheet.
|
|
45
|
+
this.openValue = false
|
|
32
46
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
|
|
33
47
|
this.overlay = document.body.lastElementChild
|
|
48
|
+
this.invoker?.setAttribute("aria-expanded", "true")
|
|
34
49
|
}
|
|
35
50
|
}
|
|
@@ -11,8 +11,16 @@ module PhlexKit
|
|
|
11
11
|
# untouched — keep the label in a real element there. Attrs pass through via
|
|
12
12
|
# mix. See sidebar.rb.
|
|
13
13
|
class SidebarMenuButton < BaseComponent
|
|
14
|
+
# `as:` is dispatched dynamically (send) — whitelist so it can't reach
|
|
15
|
+
# arbitrary (including private) methods; to_sym so a String "a" still
|
|
16
|
+
# earns aria-current when active.
|
|
17
|
+
AS_TAGS = %i[button a].freeze
|
|
18
|
+
|
|
14
19
|
def initialize(as: :button, active: false, tooltip: nil, **attrs)
|
|
15
|
-
@as = as
|
|
20
|
+
@as = as.to_sym
|
|
21
|
+
unless AS_TAGS.include?(@as)
|
|
22
|
+
raise ArgumentError, "unknown SidebarMenuButton as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
23
|
+
end
|
|
16
24
|
@active = active
|
|
17
25
|
@tooltip = tooltip
|
|
18
26
|
@attrs = attrs
|
|
@@ -5,8 +5,15 @@ module PhlexKit
|
|
|
5
5
|
# primary). Kwarg rather than attr for the same mix-merge reason as
|
|
6
6
|
# SidebarMenuButton. See sidebar.rb.
|
|
7
7
|
class SidebarMenuSubButton < BaseComponent
|
|
8
|
+
# `as:` is dispatched dynamically (send) — whitelist, matching
|
|
9
|
+
# SidebarMenuButton.
|
|
10
|
+
AS_TAGS = %i[a button].freeze
|
|
11
|
+
|
|
8
12
|
def initialize(as: :a, active: false, **attrs)
|
|
9
|
-
@as = as
|
|
13
|
+
@as = as.to_sym
|
|
14
|
+
unless AS_TAGS.include?(@as)
|
|
15
|
+
raise ArgumentError, "unknown SidebarMenuSubButton as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
16
|
+
end
|
|
10
17
|
@active = active
|
|
11
18
|
@attrs = attrs
|
|
12
19
|
end
|
|
@@ -18,6 +18,15 @@
|
|
|
18
18
|
var(--pk-brand) var(--pk-slider-progress, 50%),
|
|
19
19
|
var(--pk-surface-2) var(--pk-slider-progress, 50%));
|
|
20
20
|
}
|
|
21
|
+
/* Chrome mirrors the native range input in RTL but the gradient stays
|
|
22
|
+
physical — repaint the fill from the right. (:dir() reads document
|
|
23
|
+
directionality, so .vertical's internal `direction: rtl` flip below
|
|
24
|
+
doesn't match; its own `to top` gradient also comes later and wins.) */
|
|
25
|
+
.pk-slider:dir(rtl)::-webkit-slider-runnable-track {
|
|
26
|
+
background: linear-gradient(to left,
|
|
27
|
+
var(--pk-brand) var(--pk-slider-progress, 50%),
|
|
28
|
+
var(--pk-surface-2) var(--pk-slider-progress, 50%));
|
|
29
|
+
}
|
|
21
30
|
.pk-slider::-webkit-slider-thumb {
|
|
22
31
|
-webkit-appearance: none;
|
|
23
32
|
width: .75rem;
|
|
@@ -57,6 +66,12 @@
|
|
|
57
66
|
.pk-slider:active::-moz-range-thumb {
|
|
58
67
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
59
68
|
}
|
|
69
|
+
/* line 13 strips the native outline in every browser — without this arm the
|
|
70
|
+
replacement ring only existed on the -webkit thumb and Firefox keyboard
|
|
71
|
+
focus was invisible. */
|
|
72
|
+
.pk-slider:focus-visible::-moz-range-thumb {
|
|
73
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
74
|
+
}
|
|
60
75
|
.pk-slider:disabled { opacity: .5; cursor: not-allowed; }
|
|
61
76
|
/* Vertical: modern writing-mode flip (min-h-40 like theirs). */
|
|
62
77
|
.pk-slider.vertical {
|
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
transform: translateX(calc(100% - 2px));
|
|
50
50
|
background: var(--pk-brand-ink);
|
|
51
51
|
}
|
|
52
|
+
/* RTL: the resting thumb sits at the physical right — checked travel must
|
|
53
|
+
slide it left or it leaves the track. */
|
|
54
|
+
.pk-switch:dir(rtl):has(.pk-switch-input:checked) .pk-switch-thumb {
|
|
55
|
+
transform: translateX(calc(-100% + 2px));
|
|
56
|
+
}
|
|
52
57
|
:root[data-theme="light"] .pk-switch { background: var(--pk-input); }
|
|
53
58
|
:root[data-theme="light"] .pk-switch:has(.pk-switch-input:checked) { background: var(--pk-brand); }
|
|
54
59
|
:root[data-theme="light"] .pk-switch-thumb { background: var(--pk-bg) !important; }
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
.pk-table-wrapper { position: relative; width: 100%; overflow: auto; }
|
|
6
6
|
.pk-table { width: 100%; border-collapse: collapse; caption-side: bottom; font-size: .875rem; }
|
|
7
7
|
|
|
8
|
-
.pk-table-header .pk-table-row { border-bottom: 1px solid var(--pk-border); }
|
|
9
8
|
.pk-table-body .pk-table-row:last-child { border-bottom: 0; }
|
|
10
9
|
.pk-table-footer { border-top: 1px solid var(--pk-border); font-weight: 500; background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
|
|
10
|
+
/* shadcn parity: the totals row doesn't draw a stray line under the table. */
|
|
11
|
+
.pk-table-footer .pk-table-row:last-child { border-bottom: 0; }
|
|
11
12
|
|
|
12
13
|
.pk-table-row { border-bottom: 1px solid var(--pk-border); transition: background-color .12s ease; }
|
|
13
14
|
.pk-table-row:hover { background: color-mix(in oklab, var(--pk-surface-2) 50%, transparent); }
|
|
@@ -22,12 +22,15 @@ module PhlexKit
|
|
|
22
22
|
#
|
|
23
23
|
# Tailwind → vanilla `.pk-table-*` (table.css), palette from the global tokens.
|
|
24
24
|
class Table < BaseComponent
|
|
25
|
-
def initialize(**attrs)
|
|
25
|
+
def initialize(wrapper: {}, **attrs)
|
|
26
|
+
@wrapper = wrapper
|
|
26
27
|
@attrs = attrs
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def view_template(&block)
|
|
30
|
-
|
|
31
|
+
# Caller attrs land on the <table>; wrapper: reaches the scroll
|
|
32
|
+
# container (e.g. to cap its height) — same escape hatch as Switch.
|
|
33
|
+
div(**mix({ class: "pk-table-wrapper" }, @wrapper)) do
|
|
31
34
|
table(**mix({ class: "pk-table" }, @attrs), &block)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class TabsContent < BaseComponent
|
|
3
|
-
|
|
3
|
+
# id: is a named kwarg (not left in **attrs) — see TabsTrigger.
|
|
4
|
+
def initialize(value:, active: false, id: nil, **attrs)
|
|
4
5
|
@value = value
|
|
5
6
|
@active = active
|
|
7
|
+
@id = id || "pk-tabs-panel-#{value}"
|
|
6
8
|
@attrs = attrs
|
|
7
9
|
end
|
|
8
10
|
def view_template(&)
|
|
@@ -11,7 +13,7 @@ module PhlexKit
|
|
|
11
13
|
# focusable, per the APG tabs pattern. `active:` (mirroring TabsTrigger's)
|
|
12
14
|
# renders the panel visible pre-JS/no-JS; the controller's sync()
|
|
13
15
|
# reconciles visibility from the active value after connect.
|
|
14
|
-
div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id:
|
|
16
|
+
div(**mix({ class: @active ? "pk-tabs-content" : "pk-tabs-content pk-hidden", role: "tabpanel", id: @id, aria_labelledby: "pk-tabs-trigger-#{@value}", tabindex: "0", data: { phlex_kit__tabs_target: "content", value: @value } }, @attrs), &)
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -2,14 +2,19 @@ module PhlexKit
|
|
|
2
2
|
class TabsList < BaseComponent
|
|
3
3
|
VARIANTS = { default: nil, line: "line" }.freeze
|
|
4
4
|
|
|
5
|
-
def initialize(variant: :default, **attrs)
|
|
5
|
+
def initialize(variant: :default, orientation: :horizontal, **attrs)
|
|
6
6
|
@variant = variant.to_sym
|
|
7
|
+
@orientation = orientation.to_sym
|
|
7
8
|
@attrs = attrs
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def view_template(&)
|
|
11
12
|
classes = [ "pk-tabs-list", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
|
|
12
|
-
|
|
13
|
+
base = { class: classes, role: "tablist", data: { phlex_kit__tabs_target: "list", action: "keydown->phlex-kit--tabs#keydown" } }
|
|
14
|
+
# Server-rendered so AT hears the right axis pre-JS / with JS off; the
|
|
15
|
+
# controller re-stamps it on connect from the root's vertical class.
|
|
16
|
+
base[:aria] = { orientation: "vertical" } if @orientation == :vertical
|
|
17
|
+
div(**mix(base, @attrs), &)
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class TabsTrigger < BaseComponent
|
|
3
|
-
|
|
3
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
4
|
+
# caller id with the deterministic one into an invalid two-token id,
|
|
5
|
+
# breaking the aria pairing pre-JS (and scopeIds' caller-id detection).
|
|
6
|
+
AS_TAGS = %i[button a].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(value:, as: :button, active: false, id: nil, **attrs)
|
|
4
9
|
@value = value
|
|
5
10
|
@as = as.to_sym
|
|
11
|
+
unless AS_TAGS.include?(@as)
|
|
12
|
+
raise ArgumentError, "unknown TabsTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
13
|
+
end
|
|
6
14
|
@active = active
|
|
15
|
+
@id = id || "pk-tabs-trigger-#{value}"
|
|
7
16
|
@attrs = attrs
|
|
8
17
|
end
|
|
9
18
|
def view_template(&)
|
|
@@ -13,7 +22,7 @@ module PhlexKit
|
|
|
13
22
|
# aria-selected and the roving tabindex are kept in sync by the
|
|
14
23
|
# controller's activeValueChanged.
|
|
15
24
|
base = {
|
|
16
|
-
class: "pk-tabs-trigger", role: "tab", id:
|
|
25
|
+
class: "pk-tabs-trigger", role: "tab", id: @id,
|
|
17
26
|
aria_selected: @active ? "true" : "false",
|
|
18
27
|
aria_controls: "pk-tabs-panel-#{@value}",
|
|
19
28
|
tabindex: @active ? "0" : "-1",
|
|
@@ -18,11 +18,25 @@ export default class extends Controller {
|
|
|
18
18
|
if (!this.storedTheme()) this.applyTheme("system")
|
|
19
19
|
}
|
|
20
20
|
this._media.addEventListener("change", this._onMediaChange)
|
|
21
|
+
// Multiple toggles on one page (header + mobile menu): each instance
|
|
22
|
+
// observes :root[data-theme] so a change applied by ANY of them (or by
|
|
23
|
+
// host code) re-syncs every toggle's pressed state — without this the
|
|
24
|
+
// stale one showed the wrong state and its first click was eaten by the
|
|
25
|
+
// echo guard in apply().
|
|
26
|
+
this._rootObserver = new MutationObserver(() => this.syncPressed())
|
|
27
|
+
this._rootObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
|
|
21
28
|
this.applyTheme(this.storedTheme() || "system")
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
disconnect() {
|
|
25
32
|
this._media?.removeEventListener("change", this._onMediaChange)
|
|
33
|
+
this._rootObserver?.disconnect()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
syncPressed() {
|
|
37
|
+
const theme = document.documentElement.getAttribute("data-theme") || "system"
|
|
38
|
+
const dark = theme === "system" ? this._media.matches : theme === "dark"
|
|
39
|
+
this.element.setAttribute("data-phlex-kit--toggle-pressed-value", dark ? "true" : "false")
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
apply(e) {
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
.pk-toast-region {
|
|
8
8
|
position: fixed;
|
|
9
9
|
z-index: 100;
|
|
10
|
-
|
|
10
|
+
/* --pk-toast-offset is stamped inline by ToastRegion(offset:). */
|
|
11
|
+
padding: var(--pk-toast-offset, 1.5rem);
|
|
11
12
|
pointer-events: none;
|
|
12
13
|
}
|
|
13
14
|
.pk-toast-region[data-position="top-left"] { top: 0; left: 0; }
|
|
@@ -12,7 +12,10 @@ module PhlexKit
|
|
|
12
12
|
|
|
13
13
|
def view_template
|
|
14
14
|
data = { slot: "action" }
|
|
15
|
-
|
|
15
|
+
# Caller action first, then dismiss — client-spawned actions dismiss
|
|
16
|
+
# their toast after onClick (Sonner parity), so server-rendered ones
|
|
17
|
+
# must too.
|
|
18
|
+
data[:action] = [ @on, "click->phlex-kit--toast#dismiss" ].compact.join(" ") if @on
|
|
16
19
|
button(**mix({ type: :button, class: "pk-toast-action", data: data }, @attrs)) { @label }
|
|
17
20
|
end
|
|
18
21
|
end
|
|
@@ -46,8 +46,11 @@ export default class extends Controller {
|
|
|
46
46
|
this.element.addEventListener("keydown", this._onKeyDown)
|
|
47
47
|
this.element.addEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
|
|
48
48
|
this.element.addEventListener("phlex-kit:toast:restart", this._onRestart)
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Region-scoped: the toaster dispatches pause/resume on its own list, so
|
|
50
|
+
// another region's hover can't freeze (or resume) this toast's timer.
|
|
51
|
+
this._regionList = this.element.closest(".pk-toast-list") || document
|
|
52
|
+
this._regionList.addEventListener("phlex-kit:toast:pause", this._onRegionPause)
|
|
53
|
+
this._regionList.addEventListener("phlex-kit:toast:resume", this._onRegionResume)
|
|
51
54
|
|
|
52
55
|
requestAnimationFrame(() => {
|
|
53
56
|
this.element.dataset.state = "open"
|
|
@@ -79,8 +82,8 @@ export default class extends Controller {
|
|
|
79
82
|
this.element.removeEventListener("keydown", this._onKeyDown)
|
|
80
83
|
this.element.removeEventListener("phlex-kit:toast:force-dismiss", this._onForceDismiss)
|
|
81
84
|
this.element.removeEventListener("phlex-kit:toast:restart", this._onRestart)
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
this._regionList?.removeEventListener("phlex-kit:toast:pause", this._onRegionPause)
|
|
86
|
+
this._regionList?.removeEventListener("phlex-kit:toast:resume", this._onRegionResume)
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
dismiss(e) {
|
|
@@ -41,11 +41,22 @@ module PhlexKit
|
|
|
41
41
|
@duration = duration
|
|
42
42
|
@gap = gap
|
|
43
43
|
@offset = offset
|
|
44
|
+
# theme:/rich_colors: are Sonner API surface the kit deliberately does
|
|
45
|
+
# not implement (tokens are :root-scoped; there is no status palette) —
|
|
46
|
+
# fail loud rather than silently no-op (audit round 6).
|
|
44
47
|
@theme = theme.to_sym
|
|
45
|
-
@
|
|
48
|
+
unless @theme == :system
|
|
49
|
+
raise ArgumentError, "ToastRegion theme: only :system is supported — toasts follow the page theme (:root[data-theme])"
|
|
50
|
+
end
|
|
51
|
+
if rich_colors
|
|
52
|
+
raise ArgumentError, "ToastRegion rich_colors: is not supported — the kit has no status color palette (toasts follow --pk-* tokens)"
|
|
53
|
+
end
|
|
46
54
|
@close_button = close_button
|
|
47
55
|
@hotkey = hotkey
|
|
48
|
-
@dir = dir
|
|
56
|
+
@dir = dir.to_sym
|
|
57
|
+
unless %i[ltr rtl auto].include?(@dir)
|
|
58
|
+
raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
|
|
59
|
+
end
|
|
49
60
|
@flash = flash
|
|
50
61
|
# Base id for the <ol> stack; the wrapper takes "#{id}-region". Defaults
|
|
51
62
|
# keep the historical "pk-toaster"/"pk-toaster-region" pair — pass a
|
|
@@ -105,10 +116,12 @@ module PhlexKit
|
|
|
105
116
|
end
|
|
106
117
|
|
|
107
118
|
def region_attrs
|
|
108
|
-
{
|
|
119
|
+
base = {
|
|
109
120
|
id: "#{@id}-region",
|
|
110
121
|
role: "region",
|
|
111
122
|
class: "pk-toast-region",
|
|
123
|
+
# offset: viewport-edge padding, consumed by toast.css.
|
|
124
|
+
style: "--pk-toast-offset: #{@offset.is_a?(Numeric) ? "#{@offset}px" : @offset};",
|
|
112
125
|
# No aria-live here: each toast item is its own live region
|
|
113
126
|
# (role="status"/"alert"); a live wrapper would double-announce.
|
|
114
127
|
aria: { label: "Notifications" },
|
|
@@ -122,14 +135,11 @@ module PhlexKit
|
|
|
122
135
|
phlex_kit__toaster_max_value: @max.to_s,
|
|
123
136
|
phlex_kit__toaster_duration_value: @duration.to_s,
|
|
124
137
|
phlex_kit__toaster_gap_value: @gap.to_s,
|
|
125
|
-
|
|
126
|
-
phlex_kit__toaster_theme_value: @theme.to_s,
|
|
127
|
-
phlex_kit__toaster_rich_colors_value: @rich_colors.to_s,
|
|
128
|
-
phlex_kit__toaster_close_button_value: @close_button.to_s,
|
|
129
|
-
phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+"),
|
|
130
|
-
phlex_kit__toaster_dir_value: @dir.to_s
|
|
138
|
+
phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
|
|
131
139
|
}
|
|
132
140
|
}
|
|
141
|
+
base[:dir] = @dir.to_s unless @dir == :ltr
|
|
142
|
+
base
|
|
133
143
|
end
|
|
134
144
|
end
|
|
135
145
|
end
|
|
@@ -34,12 +34,10 @@ export default class extends Controller {
|
|
|
34
34
|
max: { type: Number, default: 3 },
|
|
35
35
|
duration: { type: Number, default: 4000 },
|
|
36
36
|
gap: { type: Number, default: 14 },
|
|
37
|
-
offset
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
closeButton: { type: Boolean, default: false },
|
|
37
|
+
// offset is a CSS custom property (--pk-toast-offset) and dir a real
|
|
38
|
+
// dir attribute, both stamped by ToastRegion; theme/richColors are
|
|
39
|
+
// unsupported (region raises) — none of them are controller state.
|
|
41
40
|
hotkey: { type: String, default: "alt+t" },
|
|
42
|
-
dir: { type: String, default: "ltr" },
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
// State the target callbacks touch lives here, NOT in connect(): Stimulus
|
|
@@ -192,7 +190,10 @@ export default class extends Controller {
|
|
|
192
190
|
// Mirror the pause state onto the DOM so toasts spawned while the stack
|
|
193
191
|
// is already hovered can arm paused (the pause event predates them).
|
|
194
192
|
this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
|
|
195
|
-
|
|
193
|
+
// Region-scoped, not document-global: on multi-region pages one region's
|
|
194
|
+
// hover must not pause (or worse, RESUME under the pointer) another's
|
|
195
|
+
// toasts. Toasts listen on their own list element.
|
|
196
|
+
this._listEl.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
_setExpanded(value) {
|
|
@@ -277,7 +278,14 @@ export default class extends Controller {
|
|
|
277
278
|
const wantCtrl = parts.includes("ctrl")
|
|
278
279
|
const wantMeta = parts.includes("meta")
|
|
279
280
|
const wantShift = parts.includes("shift")
|
|
280
|
-
|
|
281
|
+
// Match e.code as well as e.key: with Option held, macOS reports the
|
|
282
|
+
// COMPOSED character (Option+T → "†"), so alt+ hotkeys never match on
|
|
283
|
+
// e.key alone (Sonner matches event.code for the same reason).
|
|
284
|
+
const code = (e.code || "").toLowerCase()
|
|
285
|
+
const keyMatches = e.key.toLowerCase() === key.toLowerCase()
|
|
286
|
+
|| code === `key${key.toLowerCase()}`
|
|
287
|
+
|| code === `digit${key.toLowerCase()}`
|
|
288
|
+
if (!keyMatches) return
|
|
281
289
|
if (wantAlt !== e.altKey) return
|
|
282
290
|
if (wantCtrl !== e.ctrlKey) return
|
|
283
291
|
if (wantMeta !== e.metaKey) return
|
|
@@ -45,11 +45,15 @@ export default class extends Controller {
|
|
|
45
45
|
const next = items[nextIndex]
|
|
46
46
|
this.updateRovingTabindex(next)
|
|
47
47
|
next.focus()
|
|
48
|
+
// APG radio-group keyboard model: with role="radio", arrows move focus
|
|
49
|
+
// AND check the landed-on item (Home/End included).
|
|
50
|
+
if (next !== event.currentTarget) next.click()
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
reconcile() {
|
|
51
54
|
if (this.typeValue === "single") {
|
|
52
|
-
|
|
55
|
+
// A disabled button can't take focus — never hand it the tab stop.
|
|
56
|
+
const pressed = this.itemTargets.find((el) => this.isPressed(el) && !el.disabled)
|
|
53
57
|
const first = pressed || this.enabledItems()[0]
|
|
54
58
|
this.itemTargets.forEach((el) => el.setAttribute("tabindex", el === first ? "0" : "-1"))
|
|
55
59
|
} else {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class ToggleGroupItem < Toggle
|
|
3
3
|
def initialize(value:, group_context:, variant: nil, size: nil, **attrs)
|
|
4
|
+
# Toggle kwargs this item renders no markup for — inheriting them
|
|
5
|
+
# silently discarded the caller's input.
|
|
6
|
+
unsupported = attrs.keys & %i[wrapper name unpressed_value]
|
|
7
|
+
if unsupported.any?
|
|
8
|
+
raise ArgumentError, "ToggleGroupItem does not support #{unsupported.join(", ")} (group items render a bare button)"
|
|
9
|
+
end
|
|
4
10
|
@item_value = value.to_s
|
|
5
11
|
@group_context = group_context
|
|
6
12
|
pressed = group_context[:selected_values].include?(@item_value)
|
|
@@ -26,7 +32,9 @@ module PhlexKit
|
|
|
26
32
|
if @group_context[:type] == :single
|
|
27
33
|
a[:role] = "radio"
|
|
28
34
|
a[:aria] = { checked: @pressed.to_s }
|
|
29
|
-
|
|
35
|
+
# A disabled button can't take focus — giving the tab stop to a
|
|
36
|
+
# pressed-but-disabled item made the whole group Tab-unreachable.
|
|
37
|
+
a[:tabindex] = (@pressed && !@disabled) ? "0" : "-1"
|
|
30
38
|
else
|
|
31
39
|
a[:aria] = { pressed: @pressed.to_s }
|
|
32
40
|
a[:tabindex] = "0"
|
|
@@ -79,6 +79,9 @@
|
|
|
79
79
|
.pk-tooltip:hover .pk-tooltip-content {
|
|
80
80
|
opacity: 1;
|
|
81
81
|
visibility: visible;
|
|
82
|
+
/* WCAG 1.4.13 Hoverable: the revealed bubble must accept the pointer —
|
|
83
|
+
it's a DOM child of .pk-tooltip, so hovering it keeps the reveal. */
|
|
84
|
+
pointer-events: auto;
|
|
82
85
|
transition-delay: .4s, .4s;
|
|
83
86
|
}
|
|
84
87
|
}
|
|
@@ -87,9 +90,10 @@
|
|
|
87
90
|
.pk-tooltip:has(:focus-visible) .pk-tooltip-content {
|
|
88
91
|
opacity: 1;
|
|
89
92
|
visibility: visible;
|
|
93
|
+
pointer-events: auto;
|
|
90
94
|
transition-delay: 0s, 0s;
|
|
91
95
|
}
|
|
92
96
|
/* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves.
|
|
93
97
|
transition: none so the hide is immediate regardless of hover-in delays. */
|
|
94
98
|
.pk-tooltip[data-pk-dismissed]:hover .pk-tooltip-content,
|
|
95
|
-
.pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; transition: none; }
|
|
99
|
+
.pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; pointer-events: none; transition: none; }
|
|
@@ -11,7 +11,13 @@ export default class extends Controller {
|
|
|
11
11
|
const trigger = this.element.querySelector(".pk-tooltip-trigger")
|
|
12
12
|
if (content && trigger) {
|
|
13
13
|
if (!content.id) content.id = `pk-tooltip-${Math.random().toString(36).slice(2, 8)}`
|
|
14
|
-
|
|
14
|
+
// Describe the element that actually takes focus: with focusable: false
|
|
15
|
+
// the wrapper span never receives focus, so a describedby left only on
|
|
16
|
+
// it is never announced — wire the focusable child too.
|
|
17
|
+
const focusable = trigger.querySelector("button, a[href], input, select, textarea, [tabindex]")
|
|
18
|
+
;[trigger, focusable].forEach((el) => {
|
|
19
|
+
if (el && !el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", content.id)
|
|
20
|
+
})
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
this._onKeydown = (e) => {
|