phlex_kit 0.5.1 → 0.6.1
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/README.md +21 -8
- data/app/components/phlex_kit/accordion/accordion.css +2 -0
- data/app/components/phlex_kit/accordion/accordion.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
- data/app/components/phlex_kit/alert/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
- data/app/components/phlex_kit/attachment/attachment.rb +2 -2
- data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
- data/app/components/phlex_kit/avatar/avatar.rb +1 -1
- data/app/components/phlex_kit/badge/badge.rb +1 -1
- data/app/components/phlex_kit/button/button.rb +1 -1
- data/app/components/phlex_kit/button_group/button_group.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
- data/app/components/phlex_kit/card/card.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox.css +21 -12
- data/app/components/phlex_kit/combobox/combobox.rb +19 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
- data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
- data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
- data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
- data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
- data/app/components/phlex_kit/command/command.rb +17 -2
- data/app/components/phlex_kit/command/command_controller.js +93 -3
- data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
- data/app/components/phlex_kit/command/command_input.rb +7 -2
- data/app/components/phlex_kit/command/command_item.rb +12 -3
- data/app/components/phlex_kit/command/command_list.rb +9 -2
- data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
- data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
- data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
- data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
- data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
- data/app/components/phlex_kit/empty/empty_media.rb +1 -1
- data/app/components/phlex_kit/field/field.rb +1 -1
- data/app/components/phlex_kit/field/field_legend.rb +1 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
- data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
- data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
- data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
- data/app/components/phlex_kit/item/item.rb +1 -1
- data/app/components/phlex_kit/item/item_media.rb +1 -1
- data/app/components/phlex_kit/link/link.rb +1 -1
- data/app/components/phlex_kit/marker/marker.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar.css +36 -14
- data/app/components/phlex_kit/menubar/menubar.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
- data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
- data/app/components/phlex_kit/native_select/native_select.rb +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
- data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +24 -9
- data/app/components/phlex_kit/popover/popover.rb +2 -1
- data/app/components/phlex_kit/popover/popover_content.rb +6 -4
- data/app/components/phlex_kit/popover/popover_controller.js +12 -8
- data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
- data/app/components/phlex_kit/select/select.css +24 -12
- data/app/components/phlex_kit/select/select_content.rb +6 -4
- data/app/components/phlex_kit/select/select_controller.js +21 -17
- data/app/components/phlex_kit/select/select_trigger.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
- data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
- data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
- data/app/components/phlex_kit/spinner/spinner.rb +1 -1
- data/app/components/phlex_kit/switch/switch.rb +7 -4
- data/app/components/phlex_kit/tabs/tabs.rb +1 -1
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
- data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
- data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
- data/app/components/phlex_kit/toast/toast_controller.js +17 -0
- data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
- data/app/components/phlex_kit/toast/toast_item.rb +1 -1
- data/app/components/phlex_kit/toggle/toggle.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
- data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
- data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
- data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
- data/app/components/phlex_kit/typography/text.rb +1 -1
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
- data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
- data/lib/phlex_kit/base_component.rb +12 -0
- data/lib/phlex_kit/engine.rb +11 -1
- data/lib/phlex_kit/icons/lucide.rb +1 -1
- data/lib/phlex_kit/icons/phosphor.rb +1 -1
- data/lib/phlex_kit/icons/remix.rb +1 -1
- data/lib/phlex_kit/icons/tabler.rb +1 -1
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +5 -0
- metadata +2 -1
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// Connects to data-controller="phlex-kit--hover-card". Show on hover/focus
|
|
4
|
-
// small open/close delay.
|
|
3
|
+
// Connects to data-controller="phlex-kit--hover-card". Show on hover/focus
|
|
4
|
+
// with a small open/close delay. The panel is a native [popover=manual]
|
|
5
|
+
// (top layer; manual because the controller owns open/close — hover
|
|
6
|
+
// semantics want no light dismiss), anchor-positioned by hover_card.css.
|
|
5
7
|
export default class extends Controller {
|
|
6
|
-
static targets = ["content"]
|
|
8
|
+
static targets = ["trigger", "content"]
|
|
7
9
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
8
|
-
show() {
|
|
9
|
-
|
|
10
|
+
show() {
|
|
11
|
+
clearTimeout(this.t)
|
|
12
|
+
this.t = setTimeout(() => {
|
|
13
|
+
if (!this.contentTarget.matches(":popover-open")) this.contentTarget.showPopover()
|
|
14
|
+
this.contentTarget.dataset.state = "open"
|
|
15
|
+
}, this.openDelayValue)
|
|
16
|
+
}
|
|
17
|
+
hide() {
|
|
18
|
+
clearTimeout(this.t)
|
|
19
|
+
this.t = setTimeout(() => {
|
|
20
|
+
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
21
|
+
this.contentTarget.dataset.state = "closed"
|
|
22
|
+
}, this.closeDelayValue)
|
|
23
|
+
}
|
|
10
24
|
disconnect() { clearTimeout(this.t) }
|
|
11
25
|
}
|
|
@@ -17,7 +17,7 @@ module PhlexKit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def view_template(&)
|
|
20
|
-
div(**mix({ class: "pk-input-group-addon #{ALIGNS
|
|
20
|
+
div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}", role: "group" }, @attrs), &)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -6,16 +6,19 @@ module PhlexKit
|
|
|
6
6
|
# InputOtpGroup(InputOtpSlot…) [+ InputOtpSeparator + more groups]; slot
|
|
7
7
|
# count must total `length:`. `.pk-input-otp*` (input_otp.css).
|
|
8
8
|
class InputOtp < BaseComponent
|
|
9
|
-
def initialize(length: 6, name: nil, value: nil, **attrs)
|
|
9
|
+
def initialize(length: 6, name: nil, value: nil, label: "One-time code", **attrs)
|
|
10
10
|
@length = length
|
|
11
11
|
@name = name
|
|
12
12
|
@value = value
|
|
13
|
+
@label = label
|
|
13
14
|
@attrs = attrs
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def view_template(&block)
|
|
17
18
|
div(**mix({
|
|
18
19
|
class: "pk-input-otp",
|
|
20
|
+
role: "group",
|
|
21
|
+
aria: { label: @label },
|
|
19
22
|
data: {
|
|
20
23
|
controller: "phlex-kit--input-otp",
|
|
21
24
|
phlex_kit__input_otp_length_value: @length
|
|
@@ -10,6 +10,13 @@ export default class extends Controller {
|
|
|
10
10
|
static values = { length: Number }
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
|
+
// Slots compose without knowing their position — label them here so AT
|
|
14
|
+
// announces "Digit N of M" instead of anonymous edit fields.
|
|
15
|
+
this.slotTargets.forEach((slot, i) => {
|
|
16
|
+
if (!slot.hasAttribute("aria-label")) {
|
|
17
|
+
slot.setAttribute("aria-label", `Digit ${i + 1} of ${this.slotTargets.length}`)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
13
20
|
this.syncValue()
|
|
14
21
|
}
|
|
15
22
|
|
|
@@ -39,7 +46,11 @@ export default class extends Controller {
|
|
|
39
46
|
|
|
40
47
|
onPaste(e) {
|
|
41
48
|
e.preventDefault()
|
|
42
|
-
|
|
49
|
+
let text = (e.clipboardData?.getData("text") || "").replace(/\s/g, "")
|
|
50
|
+
// Numeric slots reject non-digits on typing; pasted text like
|
|
51
|
+
// "code: 123456" should distribute only the digits.
|
|
52
|
+
if (e.target.getAttribute("inputmode") === "numeric") text = text.replace(/\D/g, "")
|
|
53
|
+
const chars = text.split("")
|
|
43
54
|
if (!chars.length) return
|
|
44
55
|
const start = this.slotTargets.indexOf(e.target)
|
|
45
56
|
this.slotTargets.slice(start).forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
@@ -17,7 +17,7 @@ module PhlexKit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def view_template(&)
|
|
20
|
-
classes = [ "pk-item", VARIANTS
|
|
20
|
+
classes = [ "pk-item", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size) ].compact.join(" ")
|
|
21
21
|
if @href
|
|
22
22
|
a(**mix({ class: classes, href: @href }, @attrs), &)
|
|
23
23
|
else
|
|
@@ -11,7 +11,7 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
div(**mix({ class: [ "pk-item-media", VARIANTS
|
|
14
|
+
div(**mix({ class: [ "pk-item-media", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ") }, @attrs), &)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -41,7 +41,7 @@ module PhlexKit
|
|
|
41
41
|
private
|
|
42
42
|
|
|
43
43
|
def classes
|
|
44
|
-
[ "pk-button", VARIANTS
|
|
44
|
+
[ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -15,7 +15,7 @@ module PhlexKit
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def view_template(&)
|
|
18
|
-
classes = [ "pk-marker", VARIANTS
|
|
18
|
+
classes = [ "pk-marker", fetch_option(VARIANTS, @variant, :variant) ].compact.join(" ")
|
|
19
19
|
if @href
|
|
20
20
|
a(**mix({ class: classes, href: @href }, @attrs), &)
|
|
21
21
|
elsif @as == :button
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
background: var(--pk-surface);
|
|
14
14
|
padding: 3px;
|
|
15
15
|
}
|
|
16
|
-
.pk-menubar-menu {
|
|
16
|
+
.pk-menubar-menu { anchor-name: --pk-menubar-menu; anchor-scope: --pk-menubar-menu; }
|
|
17
17
|
.pk-menubar-trigger {
|
|
18
18
|
display: flex;
|
|
19
19
|
align-items: center;
|
|
@@ -31,12 +31,16 @@
|
|
|
31
31
|
.pk-menubar-trigger:hover,
|
|
32
32
|
.pk-menubar-trigger[aria-expanded="true"] { background: var(--pk-surface-2); }
|
|
33
33
|
.pk-menubar-content {
|
|
34
|
-
position:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
position: fixed;
|
|
35
|
+
position-anchor: --pk-menubar-menu;
|
|
36
|
+
inset: auto;
|
|
37
|
+
position-area: block-end span-inline-end;
|
|
38
|
+
position-try-fallbacks: flip-block;
|
|
39
|
+
/* the [popover] UA style sets overflow:auto, which would clip the
|
|
40
|
+
negative-offset ::before/::after bridges below */
|
|
41
|
+
overflow: visible;
|
|
38
42
|
min-width: 9rem;
|
|
39
|
-
margin
|
|
43
|
+
margin: .5rem 0 0;
|
|
40
44
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
41
45
|
border-radius: var(--pk-radius);
|
|
42
46
|
background: var(--pk-surface);
|
|
@@ -44,7 +48,8 @@
|
|
|
44
48
|
padding: .25rem;
|
|
45
49
|
box-shadow: 0 8px 24px rgb(0 0 0 / .3);
|
|
46
50
|
}
|
|
47
|
-
/* Hover bridge over the trigger→panel gap
|
|
51
|
+
/* Hover bridge over the trigger→panel gap — both sides, since a flip
|
|
52
|
+
fallback puts the gap below the panel instead of above. */
|
|
48
53
|
.pk-menubar-content::before {
|
|
49
54
|
content: "";
|
|
50
55
|
position: absolute;
|
|
@@ -53,6 +58,14 @@
|
|
|
53
58
|
right: 0;
|
|
54
59
|
height: calc(.5rem + 1px);
|
|
55
60
|
}
|
|
61
|
+
.pk-menubar-content::after {
|
|
62
|
+
content: "";
|
|
63
|
+
position: absolute;
|
|
64
|
+
top: 100%;
|
|
65
|
+
left: 0;
|
|
66
|
+
right: 0;
|
|
67
|
+
height: calc(.5rem + 1px);
|
|
68
|
+
}
|
|
56
69
|
.pk-menubar-item {
|
|
57
70
|
display: flex;
|
|
58
71
|
align-items: center;
|
|
@@ -110,16 +123,22 @@
|
|
|
110
123
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
/* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
|
|
114
|
-
|
|
126
|
+
/* Submenu: trigger row + side panel, revealed on hover/focus — pure CSS.
|
|
127
|
+
The row is its own scoped anchor; the panel flips to the other side at
|
|
128
|
+
the viewport right edge. */
|
|
129
|
+
.pk-menubar-sub { anchor-name: --pk-menubar-sub; anchor-scope: --pk-menubar-sub; }
|
|
115
130
|
.pk-menubar-sub-chevron { margin-left: auto; width: 1rem; height: 1rem; }
|
|
116
131
|
.pk-menubar-sub-content {
|
|
117
|
-
position:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
position: fixed;
|
|
133
|
+
position-anchor: --pk-menubar-sub;
|
|
134
|
+
inset: auto;
|
|
135
|
+
position-area: inline-end span-block-end;
|
|
136
|
+
margin-top: -0.25rem;
|
|
137
|
+
position-try-fallbacks: flip-inline;
|
|
121
138
|
min-width: 9rem;
|
|
122
|
-
|
|
139
|
+
/* hover bridge over the gap — symmetric because flip tactics flip
|
|
140
|
+
margins/insets but not padding, so the flipped side needs one too */
|
|
141
|
+
padding: 0 .25rem;
|
|
123
142
|
display: none;
|
|
124
143
|
}
|
|
125
144
|
.pk-menubar-sub-viewport {
|
|
@@ -135,3 +154,6 @@
|
|
|
135
154
|
.pk-menubar-sub:focus-within > .pk-menubar-sub-content { display: block; }
|
|
136
155
|
.pk-menubar-sub:hover > .pk-menubar-sub-trigger,
|
|
137
156
|
.pk-menubar-sub:focus-within > .pk-menubar-sub-trigger { background: var(--pk-accent); }
|
|
157
|
+
/* Hiding must survive author display values (a host class like
|
|
158
|
+
display:grid would beat the UA [popover] { display: none }). */
|
|
159
|
+
.pk-menubar-content:not(:popover-open) { display: none; }
|
|
@@ -16,7 +16,7 @@ module PhlexKit
|
|
|
16
16
|
role: "menubar",
|
|
17
17
|
data: {
|
|
18
18
|
controller: "phlex-kit--menubar",
|
|
19
|
-
action: "click@window->phlex-kit--menubar#onClickOutside keydown
|
|
19
|
+
action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown"
|
|
20
20
|
}
|
|
21
21
|
}, @attrs), &)
|
|
22
22
|
end
|
|
@@ -13,9 +13,12 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
label(**mix({
|
|
15
15
|
class: "pk-menubar-item pk-menubar-checkbox-item",
|
|
16
|
-
role: "menuitemcheckbox"
|
|
16
|
+
role: "menuitemcheckbox",
|
|
17
|
+
tabindex: "-1",
|
|
18
|
+
aria: { checked: @checked ? "true" : "false" }
|
|
17
19
|
}, @attrs)) do
|
|
18
|
-
input(type: :checkbox, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked
|
|
20
|
+
input(type: :checkbox, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
|
|
21
|
+
tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
|
|
19
22
|
span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
|
|
20
23
|
render Icon.new(:check, size: nil)
|
|
21
24
|
end
|
|
@@ -3,7 +3,7 @@ module PhlexKit
|
|
|
3
3
|
class MenubarContent < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&)
|
|
6
|
-
div(**mix({ class: "pk-menubar-content
|
|
6
|
+
div(**mix({ class: "pk-menubar-content", popover: "manual", role: "menu" }, @attrs), &)
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
end
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// PhlexKit's stand-in for Radix behind shadcn's Menubar and NavigationMenu
|
|
4
|
-
//
|
|
3
|
+
// PhlexKit's stand-in for Radix behind shadcn's Menubar and NavigationMenu.
|
|
4
|
+
// Panels are native [popover=manual] elements (manual: this controller owns
|
|
5
|
+
// open/close), anchor-positioned per menu with viewport-edge flipping by
|
|
6
|
+
// menubar.css / navigation_menu.css.
|
|
7
|
+
// Click (or hover, for nav menus) opens a menu's panel; while one is open,
|
|
5
8
|
// hovering a sibling trigger switches to it; Escape/outside/item click closes.
|
|
9
|
+
// Keyboard (menubar mode): ArrowDown on a trigger opens its menu focusing the
|
|
10
|
+
// first item; ArrowDown/ArrowUp/Home/End rove over the open menu's items
|
|
11
|
+
// (skipping [data-disabled]); ArrowLeft/ArrowRight jump to the sibling menus;
|
|
12
|
+
// Escape closes and returns focus to the trigger.
|
|
6
13
|
// Connects to data-controller="phlex-kit--menubar"
|
|
7
14
|
export default class extends Controller {
|
|
8
15
|
static targets = ["menu"]
|
|
9
16
|
|
|
17
|
+
initialize() {
|
|
18
|
+
this.openMenu = null
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
toggle(e) {
|
|
11
22
|
const menu = e.currentTarget.closest("[data-phlex-kit--menubar-target=\"menu\"]")
|
|
12
|
-
this.openMenu === menu ? this.close() : this.show(menu)
|
|
23
|
+
this.openMenu === menu ? this.close() : this.show(menu, true)
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
// Hover: switches between menus while one is open (menubar), or opens
|
|
@@ -20,24 +31,101 @@ export default class extends Controller {
|
|
|
20
31
|
if (this.openMenu && this.openMenu !== menu) this.show(menu)
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
show(menu) {
|
|
24
|
-
if (this.openMenu
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
show(menu, focus = false) {
|
|
35
|
+
if (this.openMenu !== menu) {
|
|
36
|
+
this.close()
|
|
37
|
+
this.panel(menu)?.showPopover()
|
|
38
|
+
menu.querySelector("[aria-expanded]")?.setAttribute("aria-expanded", "true")
|
|
39
|
+
this.openMenu = menu
|
|
40
|
+
}
|
|
41
|
+
if (focus) this.items(menu)[0]?.focus()
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
close() {
|
|
44
|
+
close(opts = {}) {
|
|
32
45
|
const menu = this.openMenu
|
|
33
46
|
if (!menu) return
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
const panel = this.panel(menu)
|
|
48
|
+
if (panel?.matches(":popover-open")) panel.hidePopover()
|
|
49
|
+
const trigger = menu.querySelector("[aria-expanded]")
|
|
50
|
+
trigger?.setAttribute("aria-expanded", "false")
|
|
36
51
|
this.openMenu = null
|
|
52
|
+
if (opts.refocus === true) trigger?.focus()
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
onClickOutside(e) {
|
|
40
56
|
if (this.element.contains(e.target)) return
|
|
41
57
|
this.close()
|
|
42
58
|
}
|
|
59
|
+
|
|
60
|
+
// Single keydown listener on the bar (wired via data-action, so no manual
|
|
61
|
+
// cleanup): roving focus while a menu is open, ArrowDown-opens while closed.
|
|
62
|
+
onKeydown(e) {
|
|
63
|
+
if (!this.openMenu) {
|
|
64
|
+
if (e.key !== "ArrowDown") return
|
|
65
|
+
const menu = e.target.closest("[data-phlex-kit--menubar-target=\"menu\"]")
|
|
66
|
+
if (!menu) return
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
this.show(menu, true)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const items = this.items(this.openMenu)
|
|
72
|
+
const index = items.indexOf(document.activeElement)
|
|
73
|
+
switch (e.key) {
|
|
74
|
+
case "Escape":
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
this.close({ refocus: true })
|
|
77
|
+
break
|
|
78
|
+
case "ArrowDown":
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
items[(index + 1) % items.length]?.focus()
|
|
81
|
+
break
|
|
82
|
+
case "ArrowUp":
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
items[index < 0 ? items.length - 1 : (index - 1 + items.length) % items.length]?.focus()
|
|
85
|
+
break
|
|
86
|
+
case "Home":
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
items[0]?.focus()
|
|
89
|
+
break
|
|
90
|
+
case "End":
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
items[items.length - 1]?.focus()
|
|
93
|
+
break
|
|
94
|
+
case "ArrowRight":
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
this.shift(1)
|
|
97
|
+
break
|
|
98
|
+
case "ArrowLeft":
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
this.shift(-1)
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Mirrors a checkbox/radio item's native input state onto the item's
|
|
106
|
+
// aria-checked (radios also reset their group's siblings).
|
|
107
|
+
syncChecked(e) {
|
|
108
|
+
const input = e.target
|
|
109
|
+
const group = input.name
|
|
110
|
+
? this.element.querySelectorAll(`input[name="${CSS.escape(input.name)}"]`)
|
|
111
|
+
: [input]
|
|
112
|
+
group.forEach((i) => i.closest("[role^=\"menuitem\"]")?.setAttribute("aria-checked", i.checked))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
shift(dir) {
|
|
116
|
+
const menus = this.menuTargets
|
|
117
|
+
const index = menus.indexOf(this.openMenu)
|
|
118
|
+
this.show(menus[(index + dir + menus.length) % menus.length], true)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
panel(menu) {
|
|
122
|
+
return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
items(menu) {
|
|
126
|
+
const panel = this.panel(menu)
|
|
127
|
+
if (!panel) return []
|
|
128
|
+
return [...panel.querySelectorAll("[role^=\"menuitem\"]")]
|
|
129
|
+
.filter((el) => !el.closest("[data-disabled]") && !el.closest(".pk-hidden"))
|
|
130
|
+
}
|
|
43
131
|
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Menu row (an <a> — pass href:, or as: :div for buttons). `variant:
|
|
3
3
|
# :destructive` tints red; `inset: true` aligns with checkbox/radio rows.
|
|
4
|
-
# Closes the bar.
|
|
4
|
+
# Closes the bar. `disabled: true` marks the row inert (skipped by roving
|
|
5
|
+
# focus, pointer-events off). See menubar.rb.
|
|
5
6
|
class MenubarItem < BaseComponent
|
|
6
7
|
VARIANTS = { default: nil, destructive: "destructive" }.freeze
|
|
7
8
|
|
|
8
|
-
def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, **attrs)
|
|
9
|
+
def initialize(as: :a, href: "#", shortcut: nil, variant: :default, inset: false, disabled: false, **attrs)
|
|
9
10
|
@as = as.to_sym
|
|
10
11
|
@href = href
|
|
11
12
|
@shortcut = shortcut
|
|
12
13
|
@variant = variant.to_sym
|
|
13
14
|
@inset = inset
|
|
15
|
+
@disabled = disabled
|
|
14
16
|
@attrs = attrs
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -18,10 +20,11 @@ module PhlexKit
|
|
|
18
20
|
base = {
|
|
19
21
|
role: "menuitem",
|
|
20
22
|
tabindex: "-1",
|
|
21
|
-
class: [ "pk-menubar-item", VARIANTS
|
|
22
|
-
data: { action: "click->phlex-kit--menubar#close" }
|
|
23
|
+
class: [ "pk-menubar-item", fetch_option(VARIANTS, @variant, :variant), ("inset" if @inset) ].compact.join(" "),
|
|
24
|
+
data: @disabled ? { disabled: "true" } : { action: "click->phlex-kit--menubar#close" }
|
|
23
25
|
}
|
|
24
|
-
base[:
|
|
26
|
+
base[:aria] = { disabled: "true" } if @disabled
|
|
27
|
+
base[:href] = @href unless @as == :div || @disabled
|
|
25
28
|
send(@as, **mix(base, @attrs)) do
|
|
26
29
|
block&.call
|
|
27
30
|
span(class: "pk-menubar-shortcut") { @shortcut } if @shortcut
|
|
@@ -13,9 +13,12 @@ module PhlexKit
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
label(**mix({
|
|
15
15
|
class: "pk-menubar-item pk-menubar-radio-item",
|
|
16
|
-
role: "menuitemradio"
|
|
16
|
+
role: "menuitemradio",
|
|
17
|
+
tabindex: "-1",
|
|
18
|
+
aria: { checked: @checked ? "true" : "false" }
|
|
17
19
|
}, @attrs)) do
|
|
18
|
-
input(type: :radio, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked
|
|
20
|
+
input(type: :radio, class: "pk-menubar-item-input", name: @name, value: @value, checked: @checked,
|
|
21
|
+
tabindex: "-1", data: { action: "change->phlex-kit--menubar#syncChecked" })
|
|
19
22
|
span(class: "pk-menubar-item-indicator", aria: { hidden: "true" }) do
|
|
20
23
|
# A filled selection dot is geometry, not icon-library vocabulary.
|
|
21
24
|
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "currentColor") do |s|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
padding: 0;
|
|
10
10
|
list-style: none;
|
|
11
11
|
}
|
|
12
|
-
.pk-navigation-menu-item {
|
|
12
|
+
.pk-navigation-menu-item { anchor-name: --pk-nav-item; anchor-scope: --pk-nav-item; }
|
|
13
13
|
.pk-navigation-menu-trigger, .pk-navigation-menu-link {
|
|
14
14
|
display: inline-flex;
|
|
15
15
|
align-items: center;
|
|
@@ -35,15 +35,20 @@
|
|
|
35
35
|
.pk-navigation-menu-chevron { width: .75rem; height: .75rem; transition: transform .2s ease; }
|
|
36
36
|
.pk-navigation-menu-trigger[aria-expanded="true"] .pk-navigation-menu-chevron { transform: rotate(180deg); }
|
|
37
37
|
.pk-navigation-menu-content {
|
|
38
|
-
position:
|
|
38
|
+
position: fixed;
|
|
39
|
+
position-anchor: --pk-nav-item;
|
|
40
|
+
inset: auto;
|
|
41
|
+
position-area: block-end span-inline-end;
|
|
42
|
+
position-try-fallbacks: flip-block;
|
|
43
|
+
/* the [popover] UA style sets overflow:auto, which would clip the
|
|
44
|
+
negative-offset ::before bridge below */
|
|
45
|
+
overflow: visible;
|
|
39
46
|
/* invisible bridge over the gap so hovering toward the panel doesn't
|
|
40
|
-
mouseleave the nav and close it
|
|
47
|
+
mouseleave the nav and close it (the panel stays a DOM descendant of
|
|
48
|
+
the nav even in the top layer, so :hover/mouseleave still track it) */
|
|
41
49
|
--pk-nav-gap: .375rem;
|
|
42
|
-
top: 100%;
|
|
43
|
-
left: 0;
|
|
44
|
-
z-index: 50;
|
|
45
50
|
min-width: 16rem;
|
|
46
|
-
margin
|
|
51
|
+
margin: var(--pk-nav-gap) 0 0;
|
|
47
52
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
48
53
|
border-radius: var(--pk-radius);
|
|
49
54
|
background: var(--pk-surface);
|
|
@@ -60,6 +65,7 @@
|
|
|
60
65
|
align-items: flex-start;
|
|
61
66
|
gap: .25rem;
|
|
62
67
|
}
|
|
68
|
+
/* Gap bridges on both sides — a flip fallback puts the gap below. */
|
|
63
69
|
.pk-navigation-menu-content::before {
|
|
64
70
|
content: "";
|
|
65
71
|
position: absolute;
|
|
@@ -68,3 +74,14 @@
|
|
|
68
74
|
right: 0;
|
|
69
75
|
height: calc(var(--pk-nav-gap) + 1px);
|
|
70
76
|
}
|
|
77
|
+
.pk-navigation-menu-content::after {
|
|
78
|
+
content: "";
|
|
79
|
+
position: absolute;
|
|
80
|
+
top: 100%;
|
|
81
|
+
left: 0;
|
|
82
|
+
right: 0;
|
|
83
|
+
height: calc(var(--pk-nav-gap) + 1px);
|
|
84
|
+
}
|
|
85
|
+
/* Hiding must survive author display values (a host class like
|
|
86
|
+
display:grid would beat the UA [popover] { display: none }). */
|
|
87
|
+
.pk-navigation-menu-content:not(:popover-open) { display: none; }
|
|
@@ -3,7 +3,9 @@ module PhlexKit
|
|
|
3
3
|
class NavigationMenuContent < BaseComponent
|
|
4
4
|
def initialize(**attrs) = (@attrs = attrs)
|
|
5
5
|
def view_template(&)
|
|
6
|
-
|
|
6
|
+
# No role="menu" here — the panel holds plain links, not menuitems
|
|
7
|
+
# (Radix/shadcn ship no menu role on NavigationMenu content either).
|
|
8
|
+
div(**mix({ class: "pk-navigation-menu-content", popover: "manual" }, @attrs), &)
|
|
7
9
|
end
|
|
8
10
|
end
|
|
9
11
|
end
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
/* Co-located with popover.rb.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/* Co-located with popover.rb. Panel matched to nova: w-72, rounded-lg,
|
|
2
|
+
p-2.5 with a gap-2.5 column, foreground-tint ring, text-sm; Header
|
|
3
|
+
(title + muted description) stacks at gap-0.5. `align: :end` flips the
|
|
4
|
+
panel to the trigger's end edge. Tokens global.
|
|
5
|
+
The panel is a native [popover] in the top layer, anchor-positioned to
|
|
6
|
+
the trigger (Baseline 2026); position-try-fallbacks flips it above the
|
|
7
|
+
trigger when it would overflow the viewport bottom. anchor-scope keeps
|
|
8
|
+
sibling popovers from resolving to each other's anchors. */
|
|
9
|
+
.pk-popover { display: inline-block; anchor-scope: --pk-popover; }
|
|
10
|
+
.pk-popover-trigger { display: inline-block; anchor-name: --pk-popover; }
|
|
7
11
|
.pk-popover-content {
|
|
8
|
-
position:
|
|
9
|
-
|
|
12
|
+
position: fixed;
|
|
13
|
+
position-anchor: --pk-popover;
|
|
14
|
+
inset: auto;
|
|
15
|
+
position-area: block-end span-inline-end;
|
|
16
|
+
margin: .25rem 0 0;
|
|
17
|
+
position-try-fallbacks: flip-block;
|
|
18
|
+
flex-direction: column; gap: .625rem;
|
|
10
19
|
width: 18rem;
|
|
11
20
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
12
21
|
border-radius: var(--pk-radius);
|
|
@@ -14,7 +23,13 @@
|
|
|
14
23
|
font-size: .875rem;
|
|
15
24
|
box-shadow: 0 8px 24px rgb(0 0 0 / .3); outline: none;
|
|
16
25
|
}
|
|
17
|
-
|
|
26
|
+
/* display must be gated on :popover-open — an unconditional author
|
|
27
|
+
`display: flex` would beat the UA's [popover] { display: none }. */
|
|
28
|
+
.pk-popover-content:popover-open { display: flex; }
|
|
29
|
+
.pk-popover-content.end { position-area: block-end span-inline-start; }
|
|
18
30
|
.pk-popover-header { display: flex; flex-direction: column; gap: .125rem; font-size: .875rem; }
|
|
19
31
|
.pk-popover-title { margin: 0; font-size: .875rem; font-weight: 500; }
|
|
20
32
|
.pk-popover-description { margin: 0; color: var(--pk-muted); }
|
|
33
|
+
/* Hiding must survive author display values (a host class like
|
|
34
|
+
display:grid would beat the UA [popover] { display: none }). */
|
|
35
|
+
.pk-popover-content:not(:popover-open) { display: none; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Click-triggered floating panel
|
|
2
|
+
# Click-triggered floating panel — native [popover] + CSS anchor
|
|
3
|
+
# positioning with viewport-edge flipping (no @floating-ui). Ported
|
|
3
4
|
# from ruby_ui's RubyUI::Popover. Compose Popover > (PopoverTrigger +
|
|
4
5
|
# PopoverContent). phlex-kit--popover.
|
|
5
6
|
class Popover < BaseComponent
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# The floating panel of a PhlexKit::Popover
|
|
3
|
-
#
|
|
2
|
+
# The floating panel of a PhlexKit::Popover — a native [popover=auto]
|
|
3
|
+
# element (top layer, browser light dismiss) anchor-positioned to the
|
|
4
|
+
# trigger with viewport-edge flipping (popover.css). `align: :end` flips it
|
|
5
|
+
# to the trigger's end edge (their align prop). See popover.rb.
|
|
4
6
|
class PopoverContent < BaseComponent
|
|
5
7
|
ALIGNS = { start: nil, end: "end" }.freeze
|
|
6
8
|
|
|
@@ -10,8 +12,8 @@ module PhlexKit
|
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def view_template(&)
|
|
13
|
-
classes = [ "pk-popover-content",
|
|
14
|
-
div(**mix({ class: classes, data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
|
|
15
|
+
classes = [ "pk-popover-content", fetch_option(ALIGNS, @align, :align) ].compact.join(" ")
|
|
16
|
+
div(**mix({ class: classes, popover: "auto", data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
-
// Connects to data-controller="phlex-kit--popover".
|
|
4
|
-
//
|
|
3
|
+
// Connects to data-controller="phlex-kit--popover". The panel is a native
|
|
4
|
+
// [popover=auto] — the browser owns light dismiss + Escape; CSS anchor
|
|
5
|
+
// positioning places it (popover.css). Light dismiss fires on pointerdown
|
|
6
|
+
// outside the panel, so a click on the trigger would close-then-reopen:
|
|
7
|
+
// armToggle records the open state at pointerdown and toggle skips the
|
|
8
|
+
// reopen.
|
|
5
9
|
export default class extends Controller {
|
|
6
|
-
static targets = ["content"]
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
static targets = ["trigger", "content"]
|
|
11
|
+
armToggle() { this.wasOpen = this.contentTarget.matches(":popover-open") }
|
|
12
|
+
toggle(e) { e?.preventDefault(); if (this.wasOpen) return; this.contentTarget.showPopover() }
|
|
13
|
+
contentTargetConnected(el) {
|
|
14
|
+
el.addEventListener("toggle", (e) => { el.dataset.state = e.newState === "open" ? "open" : "closed" })
|
|
15
|
+
}
|
|
12
16
|
}
|