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
|
@@ -2,12 +2,16 @@ module PhlexKit
|
|
|
2
2
|
# Muted helper text under a PhlexKit::FormField control. Hidden when empty.
|
|
3
3
|
# See form_field.rb.
|
|
4
4
|
class FormFieldHint < BaseComponent
|
|
5
|
-
|
|
5
|
+
# A default id (like FormFieldError) so the controller can wire the hint
|
|
6
|
+
# into every control's aria-describedby — without it SR users never heard
|
|
7
|
+
# the hint text. id: is a named kwarg to avoid mix id-fusion.
|
|
8
|
+
def initialize(id: nil, **attrs)
|
|
9
|
+
@id = id || "pk-form-field-hint-#{SecureRandom.hex(4)}"
|
|
6
10
|
@attrs = attrs
|
|
7
11
|
end
|
|
8
12
|
|
|
9
13
|
def view_template(&block)
|
|
10
|
-
p(**mix({ class: "pk-form-field-hint" }, @attrs), &block)
|
|
14
|
+
p(**mix({ id: @id, class: "pk-form-field-hint", data: { phlex_kit__form_field_target: "hint" } }, @attrs), &block)
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -15,19 +15,27 @@
|
|
|
15
15
|
position-area: block-end span-inline-end;
|
|
16
16
|
margin: .25rem 0 0;
|
|
17
17
|
position-try-fallbacks: flip-block;
|
|
18
|
+
flex-direction: column; gap: .125rem;
|
|
18
19
|
width: 16rem;
|
|
19
20
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
20
21
|
border-radius: var(--pk-radius); background: var(--pk-surface);
|
|
21
22
|
color: var(--pk-text); padding: .625rem; font-size: .875rem;
|
|
22
23
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
24
|
}
|
|
25
|
+
/* display must be gated on :popover-open — an unconditional author
|
|
26
|
+
`display: flex` would beat the UA's [popover] { display: none } (bit the
|
|
27
|
+
Basic docs example's inline style, which relied on this instead of the
|
|
28
|
+
component doing it). */
|
|
29
|
+
.pk-hover-card-content:popover-open { display: flex; }
|
|
24
30
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
31
|
+
/* Gap margins are logical to match the logical position-area (and tooltip's
|
|
32
|
+
pattern) — physical margins landed on the wrong side in RTL. */
|
|
25
33
|
.pk-hover-card-content.left {
|
|
26
|
-
position-area: inline-start; margin: 0 .25rem
|
|
34
|
+
position-area: inline-start; margin: 0; margin-inline-end: .25rem;
|
|
27
35
|
position-try-fallbacks: flip-inline;
|
|
28
36
|
}
|
|
29
37
|
.pk-hover-card-content.right {
|
|
30
|
-
position-area: inline-end; margin: 0
|
|
38
|
+
position-area: inline-end; margin: 0; margin-inline-start: .25rem;
|
|
31
39
|
position-try-fallbacks: flip-inline;
|
|
32
40
|
}
|
|
33
41
|
/* Hiding must survive author display values (a host class like
|
|
@@ -10,6 +10,29 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
10
10
|
export default class extends Controller {
|
|
11
11
|
static targets = ["trigger", "content"]
|
|
12
12
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
// Turbo snapshots BEFORE disconnect, so a stale data-state="open" would
|
|
16
|
+
// otherwise resurrect host CSS keyed on the state hook after a cache
|
|
17
|
+
// restore — close now (popover_controller.js's contentTargetConnected /
|
|
18
|
+
// context_menu_controller.js's turbo:before-cache pattern combined).
|
|
19
|
+
this.onBeforeCache = () => {
|
|
20
|
+
if (this.contentTarget.matches(":popover-open")) {
|
|
21
|
+
clearTimeout(this.t)
|
|
22
|
+
this.#close()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
contentTargetConnected(el) {
|
|
29
|
+
// A reconnect (Turbo cache restore, or the detach/reattach it's tested
|
|
30
|
+
// with) can hand us markup whose data-state was serialized while open —
|
|
31
|
+
// :popover-open does not survive the restore, so resync before anything
|
|
32
|
+
// reads the hook.
|
|
33
|
+
el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
show() {
|
|
14
37
|
clearTimeout(this.t)
|
|
15
38
|
this.t = setTimeout(() => {
|
|
@@ -25,6 +48,7 @@ export default class extends Controller {
|
|
|
25
48
|
disconnect() {
|
|
26
49
|
clearTimeout(this.t)
|
|
27
50
|
this.#unbindEscape()
|
|
51
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
#close() {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Co-located with icon.rb — PhlexKit::Icon. The .pk-icon class was emitted
|
|
2
|
+
with no rule anywhere (audit round 6): the bare SVG inherited the UA's
|
|
3
|
+
inline/baseline layout (descender gap under icons) and could be squashed
|
|
4
|
+
inside height-constrained flex scroll panes. */
|
|
5
|
+
.pk-icon {
|
|
6
|
+
display: inline-block;
|
|
7
|
+
vertical-align: middle;
|
|
8
|
+
flex: none;
|
|
9
|
+
}
|
|
@@ -20,7 +20,13 @@ module PhlexKit
|
|
|
20
20
|
|
|
21
21
|
def view_template
|
|
22
22
|
icon = Icons.fetch(@name, library: @library || PhlexKit.config.icon_library)
|
|
23
|
-
|
|
23
|
+
base = base_attrs(icon)
|
|
24
|
+
# `mix` merges duplicate string attrs ("16 24") — drop a generated attr
|
|
25
|
+
# whenever the caller supplies their own copy, so theirs wins.
|
|
26
|
+
%i[width height viewbox aria-hidden].each do |key|
|
|
27
|
+
base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
|
|
28
|
+
end
|
|
29
|
+
svg(**mix(base, @attrs)) do |s|
|
|
24
30
|
icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
|
|
25
31
|
end
|
|
26
32
|
end
|
|
@@ -15,15 +15,18 @@ module PhlexKit
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def view_template(&block)
|
|
18
|
-
|
|
18
|
+
base = {
|
|
19
19
|
class: "pk-input-otp",
|
|
20
20
|
role: "group",
|
|
21
|
-
aria: { label: @label },
|
|
22
21
|
data: {
|
|
23
22
|
controller: "phlex-kit--input-otp",
|
|
24
23
|
phlex_kit__input_otp_length_value: @length
|
|
25
24
|
}
|
|
26
|
-
}
|
|
25
|
+
}
|
|
26
|
+
# Default label only when the caller didn't supply their own — `mix`
|
|
27
|
+
# would fuse aria-label into a two-token value instead of overriding.
|
|
28
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
29
|
+
div(**mix(base, @attrs)) do
|
|
27
30
|
input(type: :hidden, name: @name, value: @value, data: { phlex_kit__input_otp_target: "value" }) if @name
|
|
28
31
|
yield if block
|
|
29
32
|
end
|
|
@@ -30,20 +30,32 @@ export default class extends Controller {
|
|
|
30
30
|
const chars = this.valueTarget.value.split("")
|
|
31
31
|
this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
32
32
|
}
|
|
33
|
+
// Track each slot's last-known-good value so a rejected keystroke (see
|
|
34
|
+
// onInput) can restore it instead of leaving the slot blanked.
|
|
35
|
+
this.slotTargets.forEach((slot) => { slot.dataset.otpValue = slot.value })
|
|
33
36
|
this.syncValue()
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
onInput(e) {
|
|
37
40
|
const slot = e.target
|
|
41
|
+
const isNumeric = slot.getAttribute("inputmode") === "numeric"
|
|
42
|
+
const priorValue = slot.dataset.otpValue || ""
|
|
38
43
|
let text = slot.value.replace(/\s/g, "")
|
|
39
44
|
// Typing rejects the same characters paste strips — a letter keyed into
|
|
40
45
|
// an inputmode=numeric slot must not land.
|
|
41
|
-
if (
|
|
46
|
+
if (isNumeric) text = text.replace(/\D/g, "")
|
|
42
47
|
|
|
43
48
|
if (text.length > 1) {
|
|
44
49
|
if (e.data && e.data.length === 1) {
|
|
50
|
+
if (isNumeric && !/\d/.test(e.data)) {
|
|
51
|
+
// Rejected keystroke into an already-filled/selected slot: restore
|
|
52
|
+
// the prior digit rather than blanking it, and don't advance focus.
|
|
53
|
+
slot.value = priorValue
|
|
54
|
+
return
|
|
55
|
+
}
|
|
45
56
|
// one keystroke into an already-filled slot: keep the newest char
|
|
46
57
|
slot.value = e.data
|
|
58
|
+
slot.dataset.otpValue = e.data
|
|
47
59
|
this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
48
60
|
this.syncValue()
|
|
49
61
|
} else {
|
|
@@ -55,7 +67,16 @@ export default class extends Controller {
|
|
|
55
67
|
return
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
if (!text && e.data) {
|
|
71
|
+
// A keystroke was fully rejected by numeric filtering (e.g. a letter
|
|
72
|
+
// typed into a filled/selected slot) — restore the slot's prior value
|
|
73
|
+
// instead of blanking it, and don't advance focus.
|
|
74
|
+
slot.value = priorValue
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
slot.value = text
|
|
79
|
+
slot.dataset.otpValue = text
|
|
59
80
|
if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
60
81
|
this.syncValue()
|
|
61
82
|
}
|
|
@@ -66,6 +87,7 @@ export default class extends Controller {
|
|
|
66
87
|
e.preventDefault()
|
|
67
88
|
const prev = this.slotTargets[index - 1]
|
|
68
89
|
prev.value = ""
|
|
90
|
+
prev.dataset.otpValue = ""
|
|
69
91
|
prev.focus()
|
|
70
92
|
this.syncValue()
|
|
71
93
|
} else if (e.key === "ArrowLeft" && index > 0) {
|
|
@@ -92,7 +114,7 @@ export default class extends Controller {
|
|
|
92
114
|
fillFrom(slot, text) {
|
|
93
115
|
const chars = text.split("")
|
|
94
116
|
const start = this.slotTargets.indexOf(slot)
|
|
95
|
-
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
|
|
117
|
+
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
|
|
96
118
|
this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
|
|
97
119
|
this.syncValue()
|
|
98
120
|
}
|
|
@@ -34,7 +34,8 @@ a.pk-item:hover { background: var(--pk-surface-2); }
|
|
|
34
34
|
transform: translateY(.125rem);
|
|
35
35
|
align-self: flex-start;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
38
|
+
.pk-item-media.icon svg:not([width]) { width: 1rem; height: 1rem; }
|
|
38
39
|
.pk-item-media.image {
|
|
39
40
|
width: 2.5rem;
|
|
40
41
|
height: 2.5rem;
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
font-weight: 500;
|
|
16
16
|
}
|
|
17
17
|
.pk-kbd-group { display: inline-flex; align-items: center; gap: .25rem; font-family: inherit; }
|
|
18
|
-
|
|
18
|
+
/* Glyphs default to size-3; explicit width attrs (Icon size:) win. */
|
|
19
|
+
.pk-kbd svg:not([width]) { width: .75rem; height: .75rem; }
|
|
19
20
|
/* Inside an (inverted) tooltip the chip tints from the page background. */
|
|
20
21
|
.pk-tooltip-content .pk-kbd {
|
|
21
22
|
background: color-mix(in oklab, var(--pk-bg) 10%, transparent);
|
|
@@ -25,7 +25,8 @@ a.pk-marker, button.pk-marker {
|
|
|
25
25
|
font-size: .875rem;
|
|
26
26
|
}
|
|
27
27
|
a.pk-marker:hover, button.pk-marker:hover { color: var(--pk-text); }
|
|
28
|
-
|
|
28
|
+
/* Glyphs default to size-4; explicit width attrs (Icon size:) win. */
|
|
29
|
+
.pk-marker svg:not([width]) { width: 1rem; height: 1rem; }
|
|
29
30
|
.pk-marker-icon { display: inline-flex; flex: none; width: 1rem; height: 1rem; }
|
|
30
31
|
.pk-marker-content { min-width: 0; overflow-wrap: break-word; }
|
|
31
32
|
.pk-marker-content a { text-decoration: underline; text-underline-offset: 3px; color: inherit; }
|
|
@@ -10,13 +10,20 @@ module PhlexKit
|
|
|
10
10
|
# unknown values used to fall through silently to <div> — fail loud instead.
|
|
11
11
|
AS_TAGS = %i[div button].freeze
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# type: is a named kwarg (not a mix default) because `mix` would *fuse* a
|
|
14
|
+
# caller type: "submit" with the generated "button" into an invalid
|
|
15
|
+
# two-token value the browser resolves back to submit.
|
|
16
|
+
def initialize(variant: :default, href: nil, as: :div, type: :button, **attrs)
|
|
14
17
|
@variant = variant.to_sym
|
|
15
18
|
@href = href
|
|
16
19
|
@as = as.to_sym
|
|
17
20
|
unless AS_TAGS.include?(@as)
|
|
18
21
|
raise ArgumentError, "unknown Marker as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
19
22
|
end
|
|
23
|
+
if @href && @as != :div
|
|
24
|
+
raise ArgumentError, "Marker href: renders an <a> — it can't combine with as: #{@as.inspect}"
|
|
25
|
+
end
|
|
26
|
+
@type = type
|
|
20
27
|
@attrs = attrs
|
|
21
28
|
end
|
|
22
29
|
|
|
@@ -25,7 +32,7 @@ module PhlexKit
|
|
|
25
32
|
if @href
|
|
26
33
|
a(**mix({ class: classes, href: @href }, @attrs), &)
|
|
27
34
|
elsif @as == :button
|
|
28
|
-
button(**mix({ class: classes, type:
|
|
35
|
+
button(**mix({ class: classes, type: @type }, @attrs), &)
|
|
29
36
|
else
|
|
30
37
|
div(**mix({ class: classes }, @attrs), &)
|
|
31
38
|
end
|
|
@@ -26,13 +26,27 @@ export default class extends Controller {
|
|
|
26
26
|
apply() {
|
|
27
27
|
const el = this.element
|
|
28
28
|
// The whole value is rewritten below, which throws the caret to the end —
|
|
29
|
-
//
|
|
30
|
-
//
|
|
29
|
+
// re-seat it after the same number of TOKEN-filled (user) characters.
|
|
30
|
+
// Counting is done in token space, not "any alphanumeric" space: mask
|
|
31
|
+
// literals can themselves be alphanumeric (the "1" in "+1 (###) ###-####"),
|
|
32
|
+
// and counting them as user input reordered every subsequent keystroke.
|
|
31
33
|
const caret = el.selectionStart ?? el.value.length
|
|
32
|
-
const
|
|
34
|
+
const filledBefore = this.maskFill(this.maskable(el.value.slice(0, caret))).tokenPos.length
|
|
33
35
|
|
|
34
|
-
const
|
|
36
|
+
const { out, tokenPos } = this.maskFill(this.maskable(el.value))
|
|
37
|
+
el.value = out
|
|
38
|
+
|
|
39
|
+
if (document.activeElement === el) {
|
|
40
|
+
const pos = filledBefore === 0 ? 0 : tokenPos[filledBefore - 1] + 1
|
|
41
|
+
el.setSelectionRange(pos, pos)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fill the mask from `raw`, returning the output and the output indices
|
|
46
|
+
// that were filled from user input (vs emitted as mask literals).
|
|
47
|
+
maskFill(raw) {
|
|
35
48
|
let out = "", i = 0
|
|
49
|
+
const tokenPos = []
|
|
36
50
|
for (const t of this.mask) {
|
|
37
51
|
if (i >= raw.length) break
|
|
38
52
|
if (t === "#" || t === "A" || t === "*") {
|
|
@@ -41,19 +55,11 @@ export default class extends Controller {
|
|
|
41
55
|
const re = t === "#" ? /\d/ : t === "A" ? /[A-Za-z]/ : /[0-9A-Za-z]/
|
|
42
56
|
while (i < raw.length && !re.test(raw[i])) i++
|
|
43
57
|
if (i >= raw.length) break
|
|
58
|
+
tokenPos.push(out.length)
|
|
44
59
|
out += raw[i++]
|
|
45
60
|
} else { out += t; if (raw[i] === t) i++ }
|
|
46
61
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (document.activeElement === el) {
|
|
50
|
-
let pos = 0, seen = 0
|
|
51
|
-
while (pos < out.length && seen < rawBefore) {
|
|
52
|
-
if (/[0-9A-Za-z]/.test(out[pos])) seen++
|
|
53
|
-
pos++
|
|
54
|
-
}
|
|
55
|
-
el.setSelectionRange(pos, pos)
|
|
56
|
-
}
|
|
62
|
+
return { out, tokenPos }
|
|
57
63
|
}
|
|
58
64
|
// Transliterate full-width digits (0-9, U+FF10–FF19 — common IME numeric
|
|
59
65
|
// output) to ASCII before the charset filter would silently drop them.
|
|
@@ -129,6 +129,9 @@
|
|
|
129
129
|
the viewport right edge. */
|
|
130
130
|
.pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
|
|
131
131
|
.pk-menubar-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
132
|
+
/* Mirror the chevron in RTL (submenu opens visually LEFT). Ancestor
|
|
133
|
+
[dir="rtl"] (not :dir()) so Chrome re-styles it on a dynamic dir flip too. */
|
|
134
|
+
[dir="rtl"] .pk-menubar-sub-chevron { transform: scaleX(-1); }
|
|
132
135
|
.pk-menubar-sub-content {
|
|
133
136
|
position: fixed;
|
|
134
137
|
position-anchor: --pk-menubar-sub;
|
|
@@ -42,6 +42,18 @@ export default class extends Controller {
|
|
|
42
42
|
if (!panel.id) panel.id = `pk-menu-panel-${++uid}`
|
|
43
43
|
trigger.setAttribute("aria-controls", panel.id)
|
|
44
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"))
|
|
51
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
52
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
53
|
+
// from it — resync every row from its live input.
|
|
54
|
+
menu.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
55
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
56
|
+
})
|
|
45
57
|
if (this.roving) this.applyRoving()
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -101,8 +113,15 @@ export default class extends Controller {
|
|
|
101
113
|
if (focus) (this.items(menu)[0] ?? this.trigger(menu))?.focus()
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
// Also wired directly as the click handler on plain nav links (see
|
|
117
|
+
// navigation_menu_link.rb) — `opts` is then the click Event rather than an
|
|
118
|
+
// options hash. Guard the default href="#" (dropdown_menu_controller.js's
|
|
119
|
+
// close() does the same) so a link without a real destination doesn't
|
|
120
|
+
// scroll-to-top/append a hash; a link WITH a real href is left to navigate
|
|
121
|
+
// normally, closing whatever panel is open on the way out.
|
|
104
122
|
close(opts = {}) {
|
|
105
123
|
clearTimeout(this.graceTimer)
|
|
124
|
+
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault?.()
|
|
106
125
|
const menu = this.openMenu
|
|
107
126
|
if (!menu) return
|
|
108
127
|
const panel = this.panel(menu)
|
|
@@ -141,10 +160,14 @@ export default class extends Controller {
|
|
|
141
160
|
e.preventDefault()
|
|
142
161
|
this.show(menu, true)
|
|
143
162
|
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
144
|
-
// Closed bar: left/right move focus between the triggers (APG).
|
|
163
|
+
// Closed bar: left/right move focus between the triggers (APG). In RTL
|
|
164
|
+
// the bar mirrors, so the physical LEFT arrow moves to the next
|
|
165
|
+
// trigger. Runtime dir check is reliable after a dynamic flip.
|
|
145
166
|
e.preventDefault()
|
|
167
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
168
|
+
const step = (e.key === "ArrowRight" ? 1 : -1) * (rtl ? -1 : 1)
|
|
146
169
|
const menus = this.menuTargets
|
|
147
|
-
const next = menus[(menus.indexOf(menu) +
|
|
170
|
+
const next = menus[(menus.indexOf(menu) + step + menus.length) % menus.length]
|
|
148
171
|
const target = next ? this.trigger(next) : null
|
|
149
172
|
target?.focus()
|
|
150
173
|
if (this.roving && target) this.applyRoving(target)
|
|
@@ -184,23 +207,30 @@ export default class extends Controller {
|
|
|
184
207
|
}
|
|
185
208
|
break
|
|
186
209
|
case "ArrowRight":
|
|
187
|
-
e.preventDefault()
|
|
188
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
189
|
-
// :focus-within) instead of jumping to the next top-level menu.
|
|
190
|
-
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
191
|
-
this.enterSub(document.activeElement)
|
|
192
|
-
} else {
|
|
193
|
-
this.shift(1)
|
|
194
|
-
}
|
|
195
|
-
break
|
|
196
210
|
case "ArrowLeft": {
|
|
197
211
|
e.preventDefault()
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
212
|
+
// Both the submenu (opens inline-end = visually LEFT in RTL) and the
|
|
213
|
+
// top-level menu traversal follow visual direction: the "enter/next"
|
|
214
|
+
// key is ArrowLeft in RTL, ArrowRight in LTR. Runtime dir check is
|
|
215
|
+
// reliable after a dynamic flip.
|
|
216
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
217
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
218
|
+
if (e.key === enterKey) {
|
|
219
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
220
|
+
// :focus-within) instead of jumping to the next top-level menu.
|
|
221
|
+
if (document.activeElement?.matches(".pk-menubar-sub-trigger")) {
|
|
222
|
+
this.enterSub(document.activeElement)
|
|
223
|
+
} else {
|
|
224
|
+
this.shift(1)
|
|
225
|
+
}
|
|
202
226
|
} else {
|
|
203
|
-
|
|
227
|
+
// Inside a submenu, step back to its trigger instead of switching menus.
|
|
228
|
+
const sub = document.activeElement?.closest(".pk-menubar-sub-content")
|
|
229
|
+
if (sub) {
|
|
230
|
+
sub.closest(".pk-menubar-sub")?.querySelector(".pk-menubar-sub-trigger")?.focus()
|
|
231
|
+
} else {
|
|
232
|
+
this.shift(-1)
|
|
233
|
+
}
|
|
204
234
|
}
|
|
205
235
|
break
|
|
206
236
|
}
|
|
@@ -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(&)
|
|
@@ -378,6 +378,9 @@ export default class extends Controller {
|
|
|
378
378
|
button.setAttribute("data-active", active ? "true" : "false");
|
|
379
379
|
// Remove the inert button from the tab order so there are no ghost stops.
|
|
380
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");
|
|
381
384
|
});
|
|
382
385
|
}
|
|
383
386
|
}
|
|
@@ -33,6 +33,20 @@
|
|
|
33
33
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
34
34
|
}
|
|
35
35
|
.pk-native-select-field:disabled { cursor: not-allowed; opacity: .5; }
|
|
36
|
+
/* [size] (a multi-row list box, NativeSelect.new(size: N)) or [multiple]
|
|
37
|
+
renders as a scrollable option list, not a single-line control — the fixed
|
|
38
|
+
2rem row height squashes it and there's no single line for the absolute
|
|
39
|
+
chevron to sit in, so auto-height the field and hide the chevron.
|
|
40
|
+
Note: [size="1"] is a single-line dropdown, not a list box — exclude it. */
|
|
41
|
+
.pk-native-select-field[size]:not([size="1"]),
|
|
42
|
+
.pk-native-select-field[multiple] {
|
|
43
|
+
height: auto;
|
|
44
|
+
padding-inline-end: .625rem;
|
|
45
|
+
}
|
|
46
|
+
.pk-native-select:has(.pk-native-select-field[size]:not([size="1"])) .pk-native-select-icon,
|
|
47
|
+
.pk-native-select:has(.pk-native-select-field[multiple]) .pk-native-select-icon {
|
|
48
|
+
display: none;
|
|
49
|
+
}
|
|
36
50
|
.pk-native-select-field.sm {
|
|
37
51
|
height: 1.75rem;
|
|
38
52
|
padding-top: .125rem;
|
|
@@ -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
|
|
@@ -23,7 +23,10 @@ module PhlexKit
|
|
|
23
23
|
# mouseenter->cancelClose: re-entering the nav over list padding /
|
|
24
24
|
# whitespace must cancel the pending mouseleave grace-close (only
|
|
25
25
|
# trigger/panel mouseenter did before).
|
|
26
|
-
|
|
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"
|
|
27
30
|
}
|
|
28
31
|
}, @attrs), &)
|
|
29
32
|
end
|
|
@@ -11,7 +11,14 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
a(**mix({
|
|
14
|
+
a(**mix({
|
|
15
|
+
class: "pk-navigation-menu-link",
|
|
16
|
+
href: @href,
|
|
17
|
+
# click: closes the open panel on real activation (and swallows the
|
|
18
|
+
# default href="#" the way dropdown_menu_controller.js's close() does
|
|
19
|
+
# — see menubar_controller.js#close).
|
|
20
|
+
data: { action: "mouseenter->phlex-kit--menubar#switch click->phlex-kit--menubar#close" }
|
|
21
|
+
}, @attrs), &)
|
|
15
22
|
end
|
|
16
23
|
end
|
|
17
24
|
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
|
|
@@ -15,7 +15,9 @@ module PhlexKit
|
|
|
15
15
|
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
16
16
|
# "Go to next page" over it would make AT announce the wrong language.
|
|
17
17
|
base = { href: @href, class: "pk-button ghost pk-pagination-next" }
|
|
18
|
-
|
|
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?
|
|
19
21
|
li do
|
|
20
22
|
a(**mix(base, @attrs)) do
|
|
21
23
|
span(class: "pk-pagination-label") { @label }
|
|
@@ -16,7 +16,9 @@ module PhlexKit
|
|
|
16
16
|
# custom (possibly localized) label: speaks for itself — hardcoding
|
|
17
17
|
# "Go to previous page" over it would make AT announce the wrong language.
|
|
18
18
|
base = { href: @href, class: "pk-button ghost pk-pagination-previous" }
|
|
19
|
-
|
|
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?
|
|
20
22
|
li do
|
|
21
23
|
a(**mix(base, @attrs)) do
|
|
22
24
|
render Icon.new(:chevron_left, size: nil)
|