phlex_kit 0.9.1 → 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/_tokens.css +8 -0
- 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/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
- data/app/components/phlex_kit/attachment/attachment.css +6 -3
- 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/attachment/scroll_fade_controller.js +7 -2
- data/app/components/phlex_kit/avatar/avatar.css +2 -2
- 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 +0 -4
- data/app/components/phlex_kit/bubble/bubble.css +1 -1
- data/app/components/phlex_kit/bubble/bubble.rb +4 -6
- data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
- data/app/components/phlex_kit/button_group/button_group.css +16 -16
- data/app/components/phlex_kit/calendar/calendar.css +10 -2
- data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
- data/app/components/phlex_kit/carousel/carousel.css +4 -4
- 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 +26 -3
- 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/collapsible/collapsible_controller.js +15 -6
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
- data/app/components/phlex_kit/combobox/combobox.css +3 -3
- data/app/components/phlex_kit/combobox/combobox.rb +3 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
- 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_search_input.rb +3 -1
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
- data/app/components/phlex_kit/command/command.css +2 -2
- data/app/components/phlex_kit/command/command_controller.js +63 -35
- data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
- data/app/components/phlex_kit/command/command_group.rb +5 -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_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 +5 -3
- data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
- data/app/components/phlex_kit/drawer/drawer.css +4 -4
- 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/empty/empty.css +1 -1
- data/app/components/phlex_kit/field/field.css +2 -1
- data/app/components/phlex_kit/field/field_separator.rb +1 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +29 -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/input_otp/input_otp.css +4 -4
- data/app/components/phlex_kit/marker/marker.rb +15 -2
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
- data/app/components/phlex_kit/menubar/menubar.rb +3 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
- data/app/components/phlex_kit/message/message.rb +5 -0
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
- data/app/components/phlex_kit/native_select/native_select.css +4 -2
- data/app/components/phlex_kit/native_select/native_select.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
- data/app/components/phlex_kit/pagination/pagination.rb +10 -2
- data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
- data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
- data/app/components/phlex_kit/popover/popover_controller.js +31 -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 +52 -5
- data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
- data/app/components/phlex_kit/select/select.css +5 -3
- data/app/components/phlex_kit/select/select_content.rb +9 -3
- data/app/components/phlex_kit/select/select_controller.js +59 -6
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/separator/separator.rb +12 -2
- data/app/components/phlex_kit/sheet/sheet.css +4 -2
- data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
- 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.css +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -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.css +2 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
- data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
- data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
- data/app/components/phlex_kit/toast/toast.css +3 -2
- data/app/components/phlex_kit/toast/toast_action.rb +4 -1
- data/app/components/phlex_kit/toast/toast_controller.js +13 -5
- data/app/components/phlex_kit/toast/toast_region.rb +19 -9
- data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
- data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
- data/app/components/phlex_kit/typography/heading.rb +13 -5
- 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
|
@@ -28,6 +28,9 @@ export default class extends Controller {
|
|
|
28
28
|
// set here. Roving tabindex applies only in menubar mode: a nav's
|
|
29
29
|
// triggers and links stay natural tab stops.
|
|
30
30
|
this.roving = this.element.dataset.hoverOpen === undefined
|
|
31
|
+
// Outstanding syncSub rAF handles — cancelled on disconnect so a queued
|
|
32
|
+
// frame never touches a torn-down menubar.
|
|
33
|
+
this.subFrames = new Set()
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
// Pair each trigger with its panel for AT (aria-controls), and fold the
|
|
@@ -39,6 +42,12 @@ export default class extends Controller {
|
|
|
39
42
|
if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
|
|
40
43
|
trigger.setAttribute("aria-controls", panel.id)
|
|
41
44
|
}
|
|
45
|
+
// A Turbo snapshot serializes aria-expanded="true" even though the
|
|
46
|
+
// panel's :popover-open does not survive the restore — no panel can be
|
|
47
|
+
// open at connect time, so stamp every expanded marker back to false
|
|
48
|
+
// (only on elements that already carry it: plain nav links must not
|
|
49
|
+
// grow an aria-expanded). Covers the trigger and CSS-revealed subs.
|
|
50
|
+
menu.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
42
51
|
if (this.roving) this.applyRoving()
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -48,6 +57,8 @@ export default class extends Controller {
|
|
|
48
57
|
|
|
49
58
|
disconnect() {
|
|
50
59
|
clearTimeout(this.graceTimer)
|
|
60
|
+
this.subFrames.forEach((id) => cancelAnimationFrame(id))
|
|
61
|
+
this.subFrames.clear()
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
toggle(e) {
|
|
@@ -112,6 +123,20 @@ export default class extends Controller {
|
|
|
112
123
|
this.close()
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// Tabbing out of the bar: close the open [popover=manual] panel once focus
|
|
127
|
+
// has left both the bar AND the panel (panels are top-layer popovers, but
|
|
128
|
+
// remain DOM children — check both anyway). Focus moving INTO the panel
|
|
129
|
+
// during normal navigation must not close it. relatedTarget is null when
|
|
130
|
+
// the window itself blurs — leave the menu alone then.
|
|
131
|
+
onFocusout(e) {
|
|
132
|
+
const to = e.relatedTarget
|
|
133
|
+
if (!to || this.element.contains(to)) return
|
|
134
|
+
const menu = this.openMenu
|
|
135
|
+
if (!menu) return
|
|
136
|
+
if (this.panel(menu)?.contains(to)) return
|
|
137
|
+
this.close()
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
// Single keydown listener on the bar (wired via data-action, so no manual
|
|
116
141
|
// cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
|
|
117
142
|
onKeydown(e) {
|
|
@@ -215,10 +240,12 @@ export default class extends Controller {
|
|
|
215
240
|
// state isn't settled until the event finishes dispatching.
|
|
216
241
|
syncSub(e) {
|
|
217
242
|
const sub = e.currentTarget
|
|
218
|
-
requestAnimationFrame(() => {
|
|
243
|
+
const id = requestAnimationFrame(() => {
|
|
244
|
+
this.subFrames.delete(id)
|
|
219
245
|
sub.querySelector(":scope > [aria-haspopup]")
|
|
220
246
|
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
|
|
221
247
|
})
|
|
248
|
+
this.subFrames.add(id)
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
shift(dir) {
|
|
@@ -6,7 +6,7 @@ module PhlexKit
|
|
|
6
6
|
class MenubarItem < BaseComponent
|
|
7
7
|
VARIANTS = { default: nil, destructive: "destructive" }.freeze
|
|
8
8
|
|
|
9
|
-
def initialize(as: :a, href:
|
|
9
|
+
def initialize(as: :a, href: nil, shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
|
|
10
10
|
@as = as.to_sym
|
|
11
11
|
@href = href
|
|
12
12
|
@shortcut = shortcut
|
|
@@ -24,7 +24,9 @@ module PhlexKit
|
|
|
24
24
|
data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
|
|
25
25
|
}
|
|
26
26
|
base[:aria] = { disabled: "true" } if @disabled
|
|
27
|
-
|
|
27
|
+
# No default href: "#" would make Enter/click navigate (hash change +
|
|
28
|
+
# scroll-to-top). tabindex="-1" keeps the row programmatically focusable.
|
|
29
|
+
base[:href] = @href unless @href.nil? || @as == :div || @disabled
|
|
28
30
|
send(@as, **mix(base, @attrs)) do
|
|
29
31
|
block&.call
|
|
30
32
|
span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Exclusive-choice group of MenubarRadioItems (share the same `name:`).
|
|
3
|
-
# Mirrors shadcn/ui's MenubarRadioGroup.
|
|
3
|
+
# Mirrors shadcn/ui's MenubarRadioGroup. role="group" (NOT "radiogroup"):
|
|
4
|
+
# radiogroup may not own menuitemradio children (axe aria-required-children).
|
|
5
|
+
# See menubar.rb.
|
|
4
6
|
class MenubarRadioGroup < BaseComponent
|
|
5
7
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
8
|
def view_template(&)
|
|
7
|
-
div(**mix({ class: "pk-menubar-group", role: "
|
|
9
|
+
div(**mix({ class: "pk-menubar-group", role: "group" }, @attrs), &)
|
|
8
10
|
end
|
|
9
11
|
end
|
|
10
12
|
end
|
|
@@ -3,8 +3,13 @@ module PhlexKit
|
|
|
3
3
|
# align: :end reverses the row. Compose Message > MessageContent (+ Avatar/
|
|
4
4
|
# Header/Footer); group several in a MessageGroup.
|
|
5
5
|
class Message < BaseComponent
|
|
6
|
+
ALIGNS = %i[start end].freeze
|
|
7
|
+
|
|
6
8
|
def initialize(align: :start, **attrs)
|
|
7
9
|
@align = align.to_sym
|
|
10
|
+
# Fail loud like sibling Bubble: message.css only styles data-align="end",
|
|
11
|
+
# so an unknown value silently rendered a start-aligned row.
|
|
12
|
+
raise KeyError, "unknown Message align #{@align}" unless ALIGNS.include?(@align)
|
|
8
13
|
@attrs = attrs
|
|
9
14
|
end
|
|
10
15
|
def view_template(&)
|
|
@@ -141,7 +141,8 @@ export default class extends Controller {
|
|
|
141
141
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
142
142
|
this.programmatic = false;
|
|
143
143
|
this.element.removeAttribute("data-autoscrolling");
|
|
144
|
-
|
|
144
|
+
// Stimulus target getters throw when missing — guard, don't optional-chain.
|
|
145
|
+
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
|
|
145
146
|
this.following = false;
|
|
146
147
|
this.updateButton();
|
|
147
148
|
}
|
|
@@ -184,14 +185,16 @@ export default class extends Controller {
|
|
|
184
185
|
// flex row gap each prepended row introduces, or the preserved row
|
|
185
186
|
// drifts down by one gap per insertion.
|
|
186
187
|
prependedHeight += (node.offsetHeight || 0) + gap;
|
|
187
|
-
} else {
|
|
188
|
-
// Inserted at
|
|
188
|
+
} else if (record.nextSibling === null) {
|
|
189
|
+
// Inserted at the end → new turn.
|
|
189
190
|
appended = node;
|
|
190
191
|
}
|
|
192
|
+
// Both siblings present → inserted between existing rows. Neither
|
|
193
|
+
// history nor a new turn — don't yank a following reader to the end.
|
|
191
194
|
}
|
|
192
195
|
}
|
|
193
196
|
|
|
194
|
-
if (prependedHeight > 0 && this.preserveOnPrependValue) {
|
|
197
|
+
if (prependedHeight > 0 && this.preserveOnPrependValue && this.hasViewportTarget) {
|
|
195
198
|
// Keep the reader's current row fixed while history loads in above.
|
|
196
199
|
this.viewportTarget.scrollTop += prependedHeight;
|
|
197
200
|
}
|
|
@@ -307,7 +310,8 @@ export default class extends Controller {
|
|
|
307
310
|
finishScroll() {
|
|
308
311
|
this.programmatic = false;
|
|
309
312
|
this.element.removeAttribute("data-autoscrolling");
|
|
310
|
-
|
|
313
|
+
// Stimulus target getters throw when missing — guard, don't optional-chain.
|
|
314
|
+
if (this.hasViewportTarget) this.viewportTarget.removeAttribute("data-autoscrolling");
|
|
311
315
|
this.following = this.isAtEnd();
|
|
312
316
|
this.updateButton();
|
|
313
317
|
}
|
|
@@ -374,6 +378,9 @@ export default class extends Controller {
|
|
|
374
378
|
button.setAttribute("data-active", active ? "true" : "false");
|
|
375
379
|
// Remove the inert button from the tab order so there are no ghost stops.
|
|
376
380
|
button.setAttribute("tabindex", active ? "0" : "-1");
|
|
381
|
+
// opacity-only hiding leaves it in the accessibility tree — a SR
|
|
382
|
+
// virtual cursor would still find a phantom jump button.
|
|
383
|
+
button.setAttribute("aria-hidden", active ? "false" : "true");
|
|
377
384
|
});
|
|
378
385
|
}
|
|
379
386
|
}
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
color: var(--pk-text);
|
|
21
21
|
font: inherit;
|
|
22
22
|
font-size: .875rem;
|
|
23
|
-
padding: .25rem
|
|
24
|
-
|
|
23
|
+
padding-block: .25rem;
|
|
24
|
+
padding-inline-start: .625rem;
|
|
25
|
+
padding-inline-end: 2rem; /* chevron space — matches .pk-native-select-icon's inset-inline-end */
|
|
26
|
+
cursor: pointer;
|
|
25
27
|
transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
|
|
26
28
|
}
|
|
27
29
|
.pk-native-select-field:hover { background: color-mix(in oklab, var(--pk-input) 50%, transparent); }
|
|
@@ -21,20 +21,29 @@ module PhlexKit
|
|
|
21
21
|
sm: "sm"
|
|
22
22
|
}.freeze
|
|
23
23
|
|
|
24
|
+
# An Integer size: is the NATIVE <select size> (multi-row list box) and
|
|
25
|
+
# passes straight through — the kit's visual sizes stay symbols.
|
|
24
26
|
def initialize(size: :default, **attrs)
|
|
25
|
-
|
|
27
|
+
if size.is_a?(Integer)
|
|
28
|
+
@native_size = size
|
|
29
|
+
@size = :default
|
|
30
|
+
else
|
|
31
|
+
@size = size.to_sym
|
|
32
|
+
end
|
|
26
33
|
@attrs = attrs
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
def view_template(&block)
|
|
30
37
|
div(class: "pk-native-select") do
|
|
31
|
-
|
|
38
|
+
base = {
|
|
32
39
|
class: classes,
|
|
33
40
|
data: {
|
|
34
41
|
phlex_kit__form_field_target: "input",
|
|
35
42
|
action: "change->phlex-kit--form-field#onChange invalid->phlex-kit--form-field#onInvalid"
|
|
36
43
|
}
|
|
37
|
-
}
|
|
44
|
+
}
|
|
45
|
+
base[:size] = @native_size if @native_size
|
|
46
|
+
select(**mix(base, @attrs), &block)
|
|
38
47
|
render PhlexKit::NativeSelectIcon.new
|
|
39
48
|
end
|
|
40
49
|
end
|
|
@@ -20,7 +20,13 @@ module PhlexKit
|
|
|
20
20
|
# focus returned to the trigger (bare #close as an action received
|
|
21
21
|
# the event as `opts`, so refocus never happened — focus fell to
|
|
22
22
|
# <body> when the panel hid).
|
|
23
|
-
|
|
23
|
+
# mouseenter->cancelClose: re-entering the nav over list padding /
|
|
24
|
+
# whitespace must cancel the pending mouseleave grace-close (only
|
|
25
|
+
# trigger/panel mouseenter did before).
|
|
26
|
+
# focusout->onFocusout: a keyboard user tabbing past the panel's
|
|
27
|
+
# last link must close it (menubar wires the same handler; closeSoon
|
|
28
|
+
# is pointer-only and never fires for this path).
|
|
29
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout mouseleave->phlex-kit--menubar#closeSoon mouseenter->phlex-kit--menubar#cancelClose"
|
|
24
30
|
}
|
|
25
31
|
}, @attrs), &)
|
|
26
32
|
end
|
|
@@ -2,9 +2,17 @@ module PhlexKit
|
|
|
2
2
|
# Page navigation. Ported from ruby_ui's RubyUI::Pagination. Compose Pagination >
|
|
3
3
|
# PaginationContent > PaginationItem(href:, active:) / PaginationEllipsis.
|
|
4
4
|
class Pagination < BaseComponent
|
|
5
|
-
|
|
5
|
+
# label: is a named kwarg (not a mix default) because `mix` would *fuse* a
|
|
6
|
+
# caller aria-label with the hardcoded "pagination" ("pagination Résultats").
|
|
7
|
+
def initialize(label: "pagination", **attrs)
|
|
8
|
+
@label = label
|
|
9
|
+
@attrs = attrs
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
def view_template(&)
|
|
7
|
-
|
|
13
|
+
base = { class: "pk-pagination", role: "navigation" }
|
|
14
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
15
|
+
nav(**mix(base, @attrs), &)
|
|
8
16
|
end
|
|
9
17
|
end
|
|
10
18
|
end
|
|
@@ -2,15 +2,24 @@ module PhlexKit
|
|
|
2
2
|
# "Next" control (label + chevron, label hidden on small screens), ported
|
|
3
3
|
# from shadcn/ui's PaginationNext. Renders its own <li>. See pagination.rb.
|
|
4
4
|
class PaginationNext < BaseComponent
|
|
5
|
-
|
|
5
|
+
DEFAULT_LABEL = "Next"
|
|
6
|
+
|
|
7
|
+
def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
|
|
6
8
|
@href = href
|
|
7
9
|
@label = label
|
|
8
10
|
@attrs = attrs
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def view_template
|
|
14
|
+
# The English aria-label only accompanies the default English label; a
|
|
15
|
+
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
16
|
+
# "Go to next page" over it would make AT announce the wrong language.
|
|
17
|
+
base = { href: @href, class: "pk-button ghost pk-pagination-next" }
|
|
18
|
+
# ...and never over a caller-supplied aria label — `mix` would fuse
|
|
19
|
+
# the two strings instead of overriding.
|
|
20
|
+
base[:aria] = { label: "Go to next page" } if @label == DEFAULT_LABEL && !aria_labelled?
|
|
12
21
|
li do
|
|
13
|
-
a(**mix(
|
|
22
|
+
a(**mix(base, @attrs)) do
|
|
14
23
|
span(class: "pk-pagination-label") { @label }
|
|
15
24
|
render Icon.new(:chevron_right, size: nil)
|
|
16
25
|
end
|
|
@@ -3,15 +3,24 @@ module PhlexKit
|
|
|
3
3
|
# ported from shadcn/ui's PaginationPrevious. Renders its own <li>.
|
|
4
4
|
# See pagination.rb.
|
|
5
5
|
class PaginationPrevious < BaseComponent
|
|
6
|
-
|
|
6
|
+
DEFAULT_LABEL = "Previous"
|
|
7
|
+
|
|
8
|
+
def initialize(href: "#", label: DEFAULT_LABEL, **attrs)
|
|
7
9
|
@href = href
|
|
8
10
|
@label = label
|
|
9
11
|
@attrs = attrs
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def view_template
|
|
15
|
+
# The English aria-label only accompanies the default English label; a
|
|
16
|
+
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
17
|
+
# "Go to previous page" over it would make AT announce the wrong language.
|
|
18
|
+
base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
|
|
19
|
+
# ...and never over a caller-supplied aria label — `mix` would fuse
|
|
20
|
+
# the two strings instead of overriding.
|
|
21
|
+
base[:aria] = { label: "Go to previous page" } if @label == DEFAULT_LABEL && !aria_labelled?
|
|
13
22
|
li do
|
|
14
|
-
a(**mix(
|
|
23
|
+
a(**mix(base, @attrs)) do
|
|
15
24
|
render Icon.new(:chevron_left, size: nil)
|
|
16
25
|
span(class: "pk-pagination-label") { @label }
|
|
17
26
|
end
|
|
@@ -17,14 +17,23 @@ export default class extends Controller {
|
|
|
17
17
|
if (invoker) {
|
|
18
18
|
invoker.popoverTargetElement = this.contentTarget
|
|
19
19
|
} else {
|
|
20
|
+
// Button-less fallback: keyboard toggling and popup semantics don't
|
|
21
|
+
// come for free like they do with popoverTargetElement — wire Enter
|
|
22
|
+
// (and Space, off text-entry controls) plus aria-haspopup/-expanded
|
|
23
|
+
// onto the focusable child so AT users can discover and open it.
|
|
24
|
+
this.fallbackInvoker = this.triggerTarget.querySelector("input, a[href], select, textarea, [tabindex]") || this.triggerTarget
|
|
25
|
+
this.fallbackInvoker.setAttribute("aria-haspopup", "dialog")
|
|
26
|
+
this.fallbackInvoker.setAttribute("aria-expanded", this.contentTarget.matches(":popover-open") ? "true" : "false")
|
|
20
27
|
this.triggerTarget.addEventListener("pointerdown", this.armToggle)
|
|
21
28
|
this.triggerTarget.addEventListener("click", this.toggle)
|
|
29
|
+
this.triggerTarget.addEventListener("keydown", this.keyToggle)
|
|
22
30
|
}
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
disconnect() {
|
|
26
34
|
this.triggerTarget.removeEventListener("pointerdown", this.armToggle)
|
|
27
35
|
this.triggerTarget.removeEventListener("click", this.toggle)
|
|
36
|
+
this.triggerTarget.removeEventListener("keydown", this.keyToggle)
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
armToggle = () => { this.wasOpen = this.contentTarget.matches(":popover-open") }
|
|
@@ -32,8 +41,29 @@ export default class extends Controller {
|
|
|
32
41
|
if (this.wasOpen) { this.wasOpen = false; return }
|
|
33
42
|
this.contentTarget.togglePopover()
|
|
34
43
|
}
|
|
44
|
+
keyToggle = (e) => {
|
|
45
|
+
const textEntry = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)
|
|
46
|
+
if (e.key !== "Enter" && !(e.key === " " && !textEntry)) return
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
this.contentTarget.togglePopover()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One stable handler added/removed symmetrically — an anonymous listener
|
|
52
|
+
// here would pile up a duplicate on every target reconnect.
|
|
53
|
+
syncState = (e) => {
|
|
54
|
+
e.target.dataset.state = e.newState === "open" ? "open" : "closed"
|
|
55
|
+
this.fallbackInvoker?.setAttribute("aria-expanded", e.newState === "open" ? "true" : "false")
|
|
56
|
+
}
|
|
35
57
|
|
|
36
58
|
contentTargetConnected(el) {
|
|
37
|
-
|
|
59
|
+
// A Turbo snapshot serializes data-state="open" but :popover-open does
|
|
60
|
+
// not survive the restore — resync before listening so host CSS keyed
|
|
61
|
+
// on the state hook doesn't misfire.
|
|
62
|
+
el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
|
|
63
|
+
el.addEventListener("toggle", this.syncState)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
contentTargetDisconnected(el) {
|
|
67
|
+
el.removeEventListener("toggle", this.syncState)
|
|
38
68
|
}
|
|
39
69
|
}
|
|
@@ -14,4 +14,12 @@
|
|
|
14
14
|
width: 100%;
|
|
15
15
|
background: var(--pk-brand);
|
|
16
16
|
transition: transform .3s ease;
|
|
17
|
+
/* value% − 100%: slides the full-width indicator into view from the
|
|
18
|
+
inline-start edge. --pk-progress-value is a unitless 0–100 set inline
|
|
19
|
+
by progress.rb. */
|
|
20
|
+
transform: translateX(calc((var(--pk-progress-value, 0) - 100) * 1%));
|
|
21
|
+
}
|
|
22
|
+
/* RTL: the bar fills from the right, so travel flips. */
|
|
23
|
+
.pk-progress:dir(rtl) .pk-progress-indicator {
|
|
24
|
+
transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
|
|
17
25
|
}
|
|
@@ -4,21 +4,30 @@ module PhlexKit
|
|
|
4
4
|
# JS; width comes from a caller `class:`/`style:`. Tailwind → vanilla
|
|
5
5
|
# `.pk-progress*` (progress.css).
|
|
6
6
|
class Progress < BaseComponent
|
|
7
|
-
|
|
7
|
+
# value_text: is a named kwarg (not read from **attrs) because `mix`
|
|
8
|
+
# would *fuse* a caller "aria-valuetext" with the generated default.
|
|
9
|
+
def initialize(value: 0, value_text: nil, **attrs)
|
|
8
10
|
@value = value.to_f.clamp(0.0, 100.0)
|
|
11
|
+
@value_text = value_text
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def view_template
|
|
16
|
+
# %g drops float artifacts ("40.0" would be announced as "forty point
|
|
17
|
+
# zero percent"). The value travels as a custom property so the fill
|
|
18
|
+
# transform lives in progress.css, where a :dir(rtl) arm can flip it —
|
|
19
|
+
# an inline translateX always filled from the physical left.
|
|
20
|
+
v = Kernel.format("%g", @value)
|
|
13
21
|
div(**mix({
|
|
14
22
|
role: "progressbar",
|
|
15
|
-
"aria-valuenow":
|
|
23
|
+
"aria-valuenow": v,
|
|
16
24
|
"aria-valuemin": 0,
|
|
17
25
|
"aria-valuemax": 100,
|
|
18
|
-
"aria-valuetext": "#{
|
|
19
|
-
class: "pk-progress"
|
|
26
|
+
"aria-valuetext": @value_text || "#{v}%",
|
|
27
|
+
class: "pk-progress",
|
|
28
|
+
style: "--pk-progress-value: #{v};"
|
|
20
29
|
}, @attrs)) do
|
|
21
|
-
div(class: "pk-progress-indicator"
|
|
30
|
+
div(class: "pk-progress-indicator")
|
|
22
31
|
end
|
|
23
32
|
end
|
|
24
33
|
end
|
|
@@ -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
|
|
@@ -47,15 +50,21 @@ export default class extends Controller {
|
|
|
47
50
|
// constant: normalizing to 2 rescaled the pair against untouched
|
|
48
51
|
// siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
|
|
49
52
|
pairGrow: this.growOf(prev) + this.growOf(next) || 2,
|
|
53
|
+
// Snapshot the group's total grow: min/max-size percentages are shares
|
|
54
|
+
// of the WHOLE group, and untouched siblings keep their grow mid-drag.
|
|
55
|
+
groupGrow: this.groupGrow(),
|
|
50
56
|
}
|
|
51
57
|
try { handle.setPointerCapture(e.pointerId) } catch {}
|
|
52
58
|
|
|
53
59
|
const onMove = (ev) => {
|
|
60
|
+
// LTR assumption: clientX grows toward the trailing panel; RTL drag
|
|
61
|
+
// inversion is a documented limitation.
|
|
54
62
|
const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
|
|
55
63
|
const total = drag.prevSize + drag.nextSize
|
|
56
64
|
const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
|
|
66
|
+
prev.style.flexGrow = prevGrow
|
|
67
|
+
next.style.flexGrow = drag.pairGrow - prevGrow
|
|
59
68
|
this.syncValuenow(handle)
|
|
60
69
|
}
|
|
61
70
|
const onUp = () => {
|
|
@@ -71,7 +80,8 @@ export default class extends Controller {
|
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
// Arrow keys resize by 5% of the pair per press (matching the drag axis);
|
|
74
|
-
// Home/End collapse the leading panel to its min/max
|
|
83
|
+
// Home/End collapse the leading panel to its min/max (clamped to any
|
|
84
|
+
// per-panel min-size/max-size bounds, same as drags).
|
|
75
85
|
keydown(e) {
|
|
76
86
|
const handle = e.currentTarget
|
|
77
87
|
const prev = handle.previousElementSibling
|
|
@@ -96,8 +106,9 @@ export default class extends Controller {
|
|
|
96
106
|
}
|
|
97
107
|
e.preventDefault()
|
|
98
108
|
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
|
|
110
|
+
prev.style.flexGrow = prevGrow
|
|
111
|
+
next.style.flexGrow = pairGrow - prevGrow
|
|
101
112
|
this.syncValuenow(handle)
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -106,6 +117,42 @@ export default class extends Controller {
|
|
|
106
117
|
return Number.isFinite(g) ? g : 1
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// Clamp the leading panel's proposed flex-grow to both panels' optional
|
|
121
|
+
// min-size/max-size percentages. Bounds are shares of the whole group
|
|
122
|
+
// (grow / groupGrow), and the pair's combined grow is fixed, so a bound on
|
|
123
|
+
// one panel implies the complementary bound on its neighbour. Without any
|
|
124
|
+
// bounds this collapses to the historical [0, pairGrow] clamp.
|
|
125
|
+
clampPrevGrow(prev, next, prevGrow, pairGrow, groupGrow) {
|
|
126
|
+
const pb = this.boundsOf(prev)
|
|
127
|
+
const nb = this.boundsOf(next)
|
|
128
|
+
let lo = 0
|
|
129
|
+
let hi = pairGrow
|
|
130
|
+
if (pb.min !== null) lo = Math.max(lo, pb.min * groupGrow)
|
|
131
|
+
if (nb.max !== null) lo = Math.max(lo, pairGrow - nb.max * groupGrow)
|
|
132
|
+
if (pb.max !== null) hi = Math.min(hi, pb.max * groupGrow)
|
|
133
|
+
if (nb.min !== null) hi = Math.min(hi, pairGrow - nb.min * groupGrow)
|
|
134
|
+
if (lo > hi) return Math.min(Math.max(prevGrow, 0), pairGrow) // unsatisfiable bounds: ignore them
|
|
135
|
+
return Math.min(Math.max(prevGrow, lo), hi)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A panel's declared size bounds as fractions of the group, or nulls.
|
|
139
|
+
boundsOf(el) {
|
|
140
|
+
const min = parseFloat(el.getAttribute("data-phlex-kit--resizable-min-size"))
|
|
141
|
+
const max = parseFloat(el.getAttribute("data-phlex-kit--resizable-max-size"))
|
|
142
|
+
return {
|
|
143
|
+
min: Number.isFinite(min) ? min / 100 : null,
|
|
144
|
+
max: Number.isFinite(max) ? max / 100 : null,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Combined flex-grow of this group's own panels (direct children only —
|
|
149
|
+
// panels of a nested group belong to that group's own controller).
|
|
150
|
+
groupGrow() {
|
|
151
|
+
return [...this.element.children]
|
|
152
|
+
.filter((el) => el.matches('[data-phlex-kit--resizable-target~="panel"]'))
|
|
153
|
+
.reduce((sum, el) => sum + this.growOf(el), 0) || 2
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
syncValuenow(handle) {
|
|
110
157
|
const prev = handle.previousElementSibling
|
|
111
158
|
const next = handle.nextElementSibling
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# One pane of a ResizablePanelGroup. `default_size:`
|
|
2
|
+
# One pane of a ResizablePanelGroup. `default_size:`, `min_size:` and
|
|
3
|
+
# `max_size:` are percentages of the group (shadcn's defaultSize/minSize/
|
|
4
|
+
# maxSize); the bounds ride along as data attributes the resizable
|
|
5
|
+
# controller clamps drags and keyboard resizes against.
|
|
3
6
|
# See resizable_panel_group.rb.
|
|
4
7
|
class ResizablePanel < BaseComponent
|
|
5
|
-
def initialize(default_size: nil, **attrs)
|
|
6
|
-
# Numeric coercion: the
|
|
8
|
+
def initialize(default_size: nil, min_size: nil, max_size: nil, **attrs)
|
|
9
|
+
# Numeric coercion: the values are interpolated into attributes, and
|
|
7
10
|
# hosts plausibly round-trip saved panel sizes from client state.
|
|
8
11
|
@default_size = default_size.nil? ? nil : Float(default_size)
|
|
12
|
+
@min_size = min_size.nil? ? nil : Float(min_size)
|
|
13
|
+
@max_size = max_size.nil? ? nil : Float(max_size)
|
|
9
14
|
@attrs = attrs
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -16,7 +21,11 @@ module PhlexKit
|
|
|
16
21
|
div(**mix({
|
|
17
22
|
class: "pk-resizable-panel",
|
|
18
23
|
style: style,
|
|
19
|
-
data: {
|
|
24
|
+
data: {
|
|
25
|
+
phlex_kit__resizable_target: "panel",
|
|
26
|
+
phlex_kit__resizable_min_size: @min_size && Kernel.format("%g", @min_size),
|
|
27
|
+
phlex_kit__resizable_max_size: @max_size && Kernel.format("%g", @max_size)
|
|
28
|
+
}.compact
|
|
20
29
|
}.compact, @attrs), &)
|
|
21
30
|
end
|
|
22
31
|
end
|
|
@@ -2,16 +2,35 @@ module PhlexKit
|
|
|
2
2
|
# Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
|
|
3
3
|
# scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
|
|
4
4
|
# Constrain it with a height/width from the caller. tabindex=0 makes the
|
|
5
|
-
# scrollable region keyboard-focusable (WCAG
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# scrollable region keyboard-focusable (WCAG scrollable-region-focusable).
|
|
6
|
+
# Pass `aria: { label: ... }` (or aria-labelledby) to name it — only then
|
|
7
|
+
# does it render role=region; a nameless region is noise for AT, so the
|
|
8
|
+
# role is omitted when unlabelled. `.pk-scroll-area` (scroll_area.css).
|
|
8
9
|
class ScrollArea < BaseComponent
|
|
9
10
|
def initialize(**attrs)
|
|
10
11
|
@attrs = attrs
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def view_template(&)
|
|
14
|
-
|
|
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")
|
|
20
|
+
div(**mix(base, @attrs), &)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# True when the caller supplies an accessible name, via the aria: hash
|
|
26
|
+
# (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
|
|
27
|
+
def labelled?
|
|
28
|
+
aria = @attrs[:aria] || @attrs["aria"]
|
|
29
|
+
if aria.is_a?(Hash)
|
|
30
|
+
return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
|
|
31
|
+
end
|
|
32
|
+
[ :aria_label, "aria_label", "aria-label",
|
|
33
|
+
:aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
|
|
15
34
|
end
|
|
16
35
|
end
|
|
17
36
|
end
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
white-space: nowrap;
|
|
23
23
|
border-radius: var(--pk-radius);
|
|
24
24
|
border: 1px solid var(--pk-input);
|
|
25
|
-
background: transparent;
|
|
26
25
|
color: var(--pk-text);
|
|
27
|
-
padding: .25rem
|
|
26
|
+
padding-block: .25rem;
|
|
27
|
+
padding-inline: .625rem .5rem;
|
|
28
28
|
gap: .375rem;
|
|
29
29
|
font: inherit;
|
|
30
30
|
font-size: .875rem;
|
|
@@ -88,7 +88,9 @@
|
|
|
88
88
|
user-select: none;
|
|
89
89
|
gap: .375rem;
|
|
90
90
|
border-radius: calc(var(--pk-radius) - 2px);
|
|
91
|
-
|
|
91
|
+
/* 2rem gutter on the inline-end — where .pk-select-item-check sits. */
|
|
92
|
+
padding-block: .25rem;
|
|
93
|
+
padding-inline: .375rem 2rem;
|
|
92
94
|
font-size: .875rem;
|
|
93
95
|
color: var(--pk-text);
|
|
94
96
|
outline: none;
|
|
@@ -5,8 +5,11 @@ module PhlexKit
|
|
|
5
5
|
# inner `.pk-select-viewport` is the bordered, scrollable box.
|
|
6
6
|
# Holds SelectGroup / SelectLabel / SelectItem children. See select.rb.
|
|
7
7
|
class SelectContent < BaseComponent
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
9
|
+
# caller id with the generated one into an invalid two-token id, breaking
|
|
10
|
+
# aria-controls and the generated item ids.
|
|
11
|
+
def initialize(id: nil, **attrs)
|
|
12
|
+
@id = id || "pk-select-content-#{SecureRandom.hex(4)}"
|
|
10
13
|
@attrs = attrs
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -17,7 +20,10 @@ module PhlexKit
|
|
|
17
20
|
tabindex: "-1",
|
|
18
21
|
class: "pk-select-content",
|
|
19
22
|
popover: "manual",
|
|
20
|
-
data: {
|
|
23
|
+
data: {
|
|
24
|
+
phlex_kit__select_target: "content",
|
|
25
|
+
action: "keydown->phlex-kit--select#typeahead"
|
|
26
|
+
}
|
|
21
27
|
}, @attrs)) do
|
|
22
28
|
div(class: "pk-select-viewport", &block)
|
|
23
29
|
end
|