phlex_kit 0.8.0 → 0.9.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 +11 -0
- data/app/components/phlex_kit/accordion/accordion.css +2 -2
- data/app/components/phlex_kit/accordion/accordion_content.rb +11 -2
- data/app/components/phlex_kit/accordion/accordion_controller.js +23 -4
- data/app/components/phlex_kit/alert/alert.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +11 -4
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +123 -6
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +10 -4
- data/app/components/phlex_kit/attachment/attachment.css +1 -1
- data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
- data/app/components/phlex_kit/avatar/avatar.css +16 -5
- data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
- data/app/components/phlex_kit/badge/badge.css +4 -4
- data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
- data/app/components/phlex_kit/bubble/bubble.css +3 -3
- data/app/components/phlex_kit/bubble/bubble.rb +2 -0
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
- data/app/components/phlex_kit/button/button.css +6 -6
- data/app/components/phlex_kit/button_group/button_group.css +2 -2
- data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
- data/app/components/phlex_kit/calendar/calendar.css +2 -2
- data/app/components/phlex_kit/calendar/calendar.rb +10 -3
- data/app/components/phlex_kit/calendar/calendar_body.rb +12 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +151 -12
- data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
- data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +11 -1
- data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
- data/app/components/phlex_kit/chart/chart_controller.js +37 -10
- data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
- data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +3 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
- data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
- data/app/components/phlex_kit/combobox/combobox.css +10 -8
- data/app/components/phlex_kit/combobox/combobox_controller.js +67 -11
- data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
- data/app/components/phlex_kit/command/command.css +4 -4
- data/app/components/phlex_kit/command/command_controller.js +51 -10
- data/app/components/phlex_kit/command/command_dialog.rb +5 -6
- data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
- data/app/components/phlex_kit/command/command_input.rb +6 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +69 -2
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
- data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
- data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
- data/app/components/phlex_kit/dialog/dialog.css +4 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
- data/app/components/phlex_kit/drawer/drawer.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +6 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +99 -45
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +6 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +5 -2
- data/app/components/phlex_kit/field/field.css +1 -1
- data/app/components/phlex_kit/field/field_error.rb +5 -6
- data/app/components/phlex_kit/form_field/form_field_controller.js +44 -12
- data/app/components/phlex_kit/form_field/form_field_error.rb +8 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
- data/app/components/phlex_kit/input/input.css +1 -1
- data/app/components/phlex_kit/input_group/input_group.css +9 -7
- data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
- data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +43 -4
- data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
- data/app/components/phlex_kit/item/item.css +1 -1
- data/app/components/phlex_kit/item/item_group.rb +4 -2
- data/app/components/phlex_kit/item/item_separator.rb +3 -1
- data/app/components/phlex_kit/marker/marker.css +1 -1
- data/app/components/phlex_kit/masked_input/masked_input.rb +1 -1
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +34 -9
- data/app/components/phlex_kit/menubar/menubar.css +10 -9
- data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
- data/app/components/phlex_kit/menubar/menubar_controller.js +119 -12
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +7 -2
- data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
- data/app/components/phlex_kit/native_select/native_select.css +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +11 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu_trigger.rb +1 -1
- data/app/components/phlex_kit/pagination/pagination.css +2 -2
- data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +1 -1
- data/app/components/phlex_kit/resizable/resizable.css +3 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +78 -3
- data/app/components/phlex_kit/resizable/resizable_handle.rb +3 -1
- data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
- data/app/components/phlex_kit/select/select.css +3 -3
- data/app/components/phlex_kit/select/select.rb +11 -7
- data/app/components/phlex_kit/select/select_controller.js +35 -4
- data/app/components/phlex_kit/select/select_item.rb +5 -2
- data/app/components/phlex_kit/sheet/sheet.css +3 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
- data/app/components/phlex_kit/sidebar/sidebar.css +27 -15
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
- data/app/components/phlex_kit/slider/slider.css +3 -3
- data/app/components/phlex_kit/slider/slider.rb +3 -1
- data/app/components/phlex_kit/switch/switch.rb +6 -1
- data/app/components/phlex_kit/table/table.css +5 -3
- data/app/components/phlex_kit/tabs/tabs.css +4 -4
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
- data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -0
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +49 -7
- data/app/components/phlex_kit/toast/toast.css +32 -14
- data/app/components/phlex_kit/toast/toast_controller.js +24 -4
- data/app/components/phlex_kit/toast/toast_region.rb +19 -5
- data/app/components/phlex_kit/toast/toaster_controller.js +20 -6
- data/app/components/phlex_kit/toggle/toggle.css +4 -4
- data/app/components/phlex_kit/toggle/toggle.rb +9 -3
- data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
- data/app/components/phlex_kit/typography/heading.rb +9 -3
- data/app/components/phlex_kit/typography/typography.css +4 -3
- data/app/javascript/phlex_kit/controllers/index.js +0 -2
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -2
- data/app/components/phlex_kit/select/select_item_controller.js +0 -14
|
@@ -59,8 +59,12 @@ export default class extends Controller {
|
|
|
59
59
|
defaultThemeColor(index) {
|
|
60
60
|
const color = this.seriesColor(index)
|
|
61
61
|
const translucentFill = ["line", "radar"].includes(this.optionsValue.type)
|
|
62
|
+
// color-mix, not hex + "33": the token may be any CSS color — the shipped
|
|
63
|
+
// zinc/neutral themes use oklch(...), and appending an alpha suffix to
|
|
64
|
+
// those produced invalid colors (fills silently broke).
|
|
65
|
+
const translucent = `color-mix(in oklab, ${color} 20%, transparent)`
|
|
62
66
|
return {
|
|
63
|
-
backgroundColor: this.isCircular() ? undefined : (translucentFill ?
|
|
67
|
+
backgroundColor: this.isCircular() ? undefined : (translucentFill ? translucent : color),
|
|
64
68
|
hoverBackgroundColor: color,
|
|
65
69
|
borderColor: color,
|
|
66
70
|
borderWidth: 2,
|
|
@@ -76,15 +80,22 @@ export default class extends Controller {
|
|
|
76
80
|
|
|
77
81
|
setDefaultColorsForChart() {
|
|
78
82
|
const Chart = this.chartLibrary()
|
|
79
|
-
|
|
80
|
-
Chart.defaults
|
|
81
|
-
|
|
83
|
+
// One computed-style resolve for all nine tokens (this runs on every
|
|
84
|
+
// connect; assigning identical values to Chart.defaults is free — charts
|
|
85
|
+
// read defaults at construction — so the style resolves are the only cost
|
|
86
|
+
// worth trimming).
|
|
87
|
+
const styles = getComputedStyle(document.documentElement)
|
|
88
|
+
const token = (name) => styles.getPropertyValue(`--${name}`).trim()
|
|
89
|
+
|
|
90
|
+
Chart.defaults.color = token("pk-muted") // font color
|
|
91
|
+
Chart.defaults.borderColor = token("pk-border") // border color
|
|
92
|
+
Chart.defaults.backgroundColor = token("pk-bg") // background color
|
|
82
93
|
|
|
83
94
|
// tooltip colors
|
|
84
|
-
Chart.defaults.plugins.tooltip.backgroundColor =
|
|
85
|
-
Chart.defaults.plugins.tooltip.borderColor =
|
|
86
|
-
Chart.defaults.plugins.tooltip.titleColor =
|
|
87
|
-
Chart.defaults.plugins.tooltip.bodyColor =
|
|
95
|
+
Chart.defaults.plugins.tooltip.backgroundColor = token("pk-surface")
|
|
96
|
+
Chart.defaults.plugins.tooltip.borderColor = token("pk-border")
|
|
97
|
+
Chart.defaults.plugins.tooltip.titleColor = token("pk-text")
|
|
98
|
+
Chart.defaults.plugins.tooltip.bodyColor = token("pk-muted")
|
|
88
99
|
Chart.defaults.plugins.tooltip.borderWidth = 1
|
|
89
100
|
|
|
90
101
|
// legend
|
|
@@ -95,7 +106,7 @@ export default class extends Controller {
|
|
|
95
106
|
Chart.defaults.plugins.legend.labels.borderRadius = 2
|
|
96
107
|
|
|
97
108
|
// shadcn look: hairline grid, no axis borders, muted ticks
|
|
98
|
-
Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color:
|
|
109
|
+
Chart.defaults.scale.grid = { ...Chart.defaults.scale.grid, color: token("pk-border"), drawTicks: false }
|
|
99
110
|
Chart.defaults.scale.border = { ...Chart.defaults.scale.border, display: false }
|
|
100
111
|
}
|
|
101
112
|
|
|
@@ -104,14 +115,30 @@ export default class extends Controller {
|
|
|
104
115
|
this.initChart()
|
|
105
116
|
}
|
|
106
117
|
|
|
107
|
-
// The kit themes via <html data-theme="...">, not a class —
|
|
118
|
+
// The kit themes via <html data-theme="...">, not a class — but a class can
|
|
119
|
+
// re-theme too (.pk-dark, custom theme classes), so watch both. ANY root
|
|
120
|
+
// class mutation fires the observer though, so only rebuild when the
|
|
121
|
+
// resolved token values actually changed — rebuilding every chart on an
|
|
122
|
+
// unrelated class flip is the expensive path.
|
|
108
123
|
initThemeObserver() {
|
|
124
|
+
this._themeKey = this.currentThemeKey()
|
|
109
125
|
this.themeObserver = new MutationObserver(() => {
|
|
126
|
+
const key = this.currentThemeKey()
|
|
127
|
+
if (key === this._themeKey) return
|
|
128
|
+
this._themeKey = key
|
|
110
129
|
this.refreshChart()
|
|
111
130
|
})
|
|
112
131
|
this.themeObserver.observe(document.documentElement, { attributeFilter: ["data-theme", "class"] })
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
// Cheap fingerprint of the resolved theme: two tokens that differ between
|
|
135
|
+
// every light/dark pair. One style resolve per mutation vs a full chart
|
|
136
|
+
// rebuild.
|
|
137
|
+
currentThemeKey() {
|
|
138
|
+
const styles = getComputedStyle(document.documentElement)
|
|
139
|
+
return `${styles.getPropertyValue("--pk-bg")}|${styles.getPropertyValue("--pk-text")}`
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
mergeOptionsWithDefaults() {
|
|
116
143
|
return {
|
|
117
144
|
...this.optionsValue,
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
height: 1rem;
|
|
17
17
|
margin: 0;
|
|
18
18
|
border: 1px solid var(--pk-input);
|
|
19
|
-
|
|
19
|
+
/* Tracks the radius token — 4px at the default --pk-radius (0.625rem). */
|
|
20
|
+
border-radius: calc(var(--pk-radius) - 6px);
|
|
20
21
|
background: color-mix(in oklab, var(--pk-input) 30%, transparent);
|
|
21
22
|
cursor: pointer;
|
|
22
23
|
transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
.pk-clipboard-popover { position: absolute; bottom: calc(100% + .5rem); left: 50%; transform: translateX(-50%); z-index: 50; width: max-content; }
|
|
5
5
|
.pk-clipboard-popover-inner {
|
|
6
6
|
border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
|
|
7
|
-
color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px
|
|
7
|
+
color: var(--pk-text); padding: .125rem .5rem; font-size: .875rem; box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
|
|
8
8
|
}
|
|
@@ -5,6 +5,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
5
5
|
export default class extends Controller {
|
|
6
6
|
static targets = ["trigger", "source", "successPopover", "errorPopover"]
|
|
7
7
|
|
|
8
|
+
disconnect() {
|
|
9
|
+
// Don't leave the 1.5s hide timer running into a dead/recycled element
|
|
10
|
+
// (Turbo can detach and re-attach this subtree mid-flash).
|
|
11
|
+
clearTimeout(this.timer)
|
|
12
|
+
this.hideAll()
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
copy() {
|
|
9
16
|
const el = this.sourceTarget.children[0]
|
|
10
17
|
if (!el) { this.show(this.errorPopoverTarget); return }
|
|
@@ -6,7 +6,9 @@ module PhlexKit
|
|
|
6
6
|
@attrs = attrs
|
|
7
7
|
end
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
|
|
9
|
+
# role="status": the shown/hidden confirmation is a live region, so
|
|
10
|
+
# screen readers announce "Copied!" instead of a silent visual flash.
|
|
11
|
+
div(class: "pk-clipboard-popover pk-hidden", role: "status", data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
|
|
10
12
|
div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -9,7 +9,10 @@ module PhlexKit
|
|
|
9
9
|
@attrs = attrs
|
|
10
10
|
end
|
|
11
11
|
def view_template(&block)
|
|
12
|
-
|
|
12
|
+
# tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
|
|
13
|
+
# be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
|
|
14
|
+
# `aria: { label: ... }` to name the region for AT.
|
|
15
|
+
div(**mix({ class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }, @attrs)) do
|
|
13
16
|
pre do
|
|
14
17
|
if block
|
|
15
18
|
code(&block)
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
+
# Collapsed region of a Collapsible. Pass `open:` matching the parent
|
|
3
|
+
# Collapsible's so a closed region is hidden before JS (no flash-open, and
|
|
4
|
+
# no stuck-open region without JS); the controller toggles `.pk-hidden`
|
|
5
|
+
# from there. The default id lets the trigger's aria-controls point here.
|
|
2
6
|
class CollapsibleContent < BaseComponent
|
|
3
|
-
def initialize(
|
|
7
|
+
def initialize(open: false, id: nil, **attrs)
|
|
8
|
+
@open = open
|
|
9
|
+
@id = id || "pk-collapsible-content-#{SecureRandom.hex(4)}"
|
|
10
|
+
@attrs = attrs
|
|
11
|
+
end
|
|
12
|
+
|
|
4
13
|
def view_template(&)
|
|
5
|
-
div(**mix({
|
|
14
|
+
div(**mix({
|
|
15
|
+
class: @open ? "pk-collapsible-content" : "pk-collapsible-content pk-hidden",
|
|
16
|
+
id: @id,
|
|
17
|
+
data: { phlex_kit__collapsible_target: "content" }
|
|
18
|
+
}, @attrs), &)
|
|
6
19
|
end
|
|
7
20
|
end
|
|
8
21
|
end
|
|
@@ -2,20 +2,29 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="phlex-kit--collapsible"
|
|
4
4
|
export default class extends Controller {
|
|
5
|
-
static targets = ["content"]
|
|
5
|
+
static targets = ["trigger", "content"]
|
|
6
6
|
static values = { open: { type: Boolean, default: false } }
|
|
7
7
|
|
|
8
|
-
connect() {
|
|
8
|
+
connect() {
|
|
9
|
+
// The trigger renders aria-expanded from its own `open:` kwarg but can't
|
|
10
|
+
// know the content's id at render time — wire aria-controls here.
|
|
11
|
+
if (this.hasTriggerTarget && this.hasContentTarget && this.contentTarget.id) {
|
|
12
|
+
this.triggerTarget.setAttribute("aria-controls", this.contentTarget.id)
|
|
13
|
+
}
|
|
14
|
+
this.openValue ? this.open() : this.close()
|
|
15
|
+
}
|
|
9
16
|
toggle() { this.openValue = !this.openValue }
|
|
10
17
|
openValueChanged(isOpen) { isOpen ? this.open() : this.close() }
|
|
11
18
|
|
|
12
19
|
open() {
|
|
13
20
|
if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
|
|
21
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
14
22
|
this.element.dataset.state = "open";
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
close() {
|
|
18
26
|
if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
|
|
27
|
+
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
19
28
|
this.element.dataset.state = "closed";
|
|
20
29
|
}
|
|
21
30
|
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
+
# Toggle for a Collapsible. Renders a plain wrapper carrying the click
|
|
3
|
+
# action — it deliberately has no role of its own, so nest a real focusable
|
|
4
|
+
# control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
|
|
5
|
+
# operability and semantics, the same composition pattern as
|
|
6
|
+
# AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
|
|
7
|
+
# aria-expanded is correct before JS; the controller keeps it in sync and
|
|
8
|
+
# wires aria-controls to the content's id in connect().
|
|
2
9
|
class CollapsibleTrigger < BaseComponent
|
|
3
|
-
def initialize(
|
|
10
|
+
def initialize(open: false, **attrs)
|
|
11
|
+
@open = open
|
|
12
|
+
@attrs = attrs
|
|
13
|
+
end
|
|
14
|
+
|
|
4
15
|
def view_template(&)
|
|
5
|
-
div(**mix({
|
|
16
|
+
div(**mix({
|
|
17
|
+
class: "pk-collapsible-trigger",
|
|
18
|
+
aria_expanded: @open ? "true" : "false",
|
|
19
|
+
data: { phlex_kit__collapsible_target: "trigger", action: "click->phlex-kit--collapsible#toggle" }
|
|
20
|
+
}, @attrs), &)
|
|
6
21
|
end
|
|
7
22
|
end
|
|
8
23
|
end
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
.pk-combobox-trigger:disabled,
|
|
42
42
|
.pk-combobox-trigger[aria-disabled="true"] { pointer-events: none; opacity: .5; }
|
|
43
43
|
.pk-combobox-trigger-content { overflow: hidden; text-overflow: ellipsis; }
|
|
44
|
-
.pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-
|
|
44
|
+
.pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
|
|
45
45
|
|
|
46
46
|
/* Dropdown panel — anchored below the combobox, flips above at the viewport
|
|
47
47
|
bottom. Width: trigger width with a 15rem floor (was JS
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
border-radius: var(--pk-radius);
|
|
59
59
|
background: var(--pk-surface);
|
|
60
60
|
color: var(--pk-text);
|
|
61
|
-
box-shadow: 0 4px 16px
|
|
61
|
+
box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/* Search row. */
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
border-bottom: 1px solid var(--pk-border);
|
|
70
70
|
color: var(--pk-muted);
|
|
71
71
|
}
|
|
72
|
-
.pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-
|
|
72
|
+
.pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-inline-end: .5rem; opacity: .5; }
|
|
73
73
|
.pk-combobox-search-input {
|
|
74
74
|
width: 100%;
|
|
75
75
|
height: 2rem;
|
|
@@ -95,8 +95,10 @@
|
|
|
95
95
|
color: var(--pk-text);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/* Labelled group — the label renders from the `label` attribute
|
|
99
|
-
|
|
98
|
+
/* Labelled group — the label renders from the `data-label` attribute (the
|
|
99
|
+
invalid bare `label` attribute was dropped; aria-label carries it for AT),
|
|
100
|
+
and the group hides itself (pure CSS) when filtering leaves no visible item
|
|
101
|
+
inside. */
|
|
100
102
|
.pk-combobox-group { display: none; }
|
|
101
103
|
.pk-combobox-group:has(label:not(.pk-hidden)) {
|
|
102
104
|
display: flex;
|
|
@@ -106,7 +108,7 @@
|
|
|
106
108
|
border-bottom: 1px solid var(--pk-border);
|
|
107
109
|
}
|
|
108
110
|
.pk-combobox-group::before {
|
|
109
|
-
content: attr(label);
|
|
111
|
+
content: attr(data-label);
|
|
110
112
|
padding: .375rem .5rem;
|
|
111
113
|
font-size: .75rem;
|
|
112
114
|
font-weight: 500;
|
|
@@ -159,7 +161,7 @@
|
|
|
159
161
|
|
|
160
162
|
/* Trailing ✓, visible while the row's input is checked. */
|
|
161
163
|
.pk-combobox-item-indicator {
|
|
162
|
-
margin-
|
|
164
|
+
margin-inline-start: auto;
|
|
163
165
|
width: 1rem;
|
|
164
166
|
height: 1rem;
|
|
165
167
|
flex: none;
|
|
@@ -314,7 +316,7 @@
|
|
|
314
316
|
align-items: center;
|
|
315
317
|
justify-content: center;
|
|
316
318
|
flex: none;
|
|
317
|
-
margin-
|
|
319
|
+
margin-inline-start: auto;
|
|
318
320
|
border: 0;
|
|
319
321
|
border-radius: .125rem;
|
|
320
322
|
padding: 0;
|
|
@@ -37,23 +37,37 @@ export default class extends Controller {
|
|
|
37
37
|
|
|
38
38
|
selectedItemIndex = null
|
|
39
39
|
|
|
40
|
+
initialize() {
|
|
41
|
+
// Stimulus fires [target]Connected before connect(): anything
|
|
42
|
+
// itemTargetConnected uses must be initialized here.
|
|
43
|
+
this.itemIdCounter = 0
|
|
44
|
+
this.refocusing = false
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
connect() {
|
|
41
48
|
this.generateItemIds()
|
|
42
49
|
this.updateTriggerContent()
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
// ARIA plumbing:
|
|
46
|
-
//
|
|
47
|
-
// and aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
52
|
+
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
53
|
+
// select_controller.js); the per-option ids come from itemTargetConnected.
|
|
48
54
|
generateItemIds() {
|
|
55
|
+
this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", this.listId()))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
listId() {
|
|
49
59
|
const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
|
|
50
60
|
if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
|
|
61
|
+
return list.id
|
|
62
|
+
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
// Every option gets an id derived from the listbox id so the combobox
|
|
65
|
+
// element can point aria-activedescendant at the keyboard highlight. A
|
|
66
|
+
// target callback (not a connect() loop) so options added after connect —
|
|
67
|
+
// e.g. rendered in by the host — get ids too. Counter-based, so a removed
|
|
68
|
+
// option's id is never reissued to a later arrival.
|
|
69
|
+
itemTargetConnected(item) {
|
|
70
|
+
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// The elements carrying the open/closed combobox state: the trigger button
|
|
@@ -120,7 +134,13 @@ export default class extends Controller {
|
|
|
120
134
|
|
|
121
135
|
toggleAllItems() {
|
|
122
136
|
const isChecked = this.toggleAllTarget.checked
|
|
123
|
-
this.inputTargets.forEach(input =>
|
|
137
|
+
this.inputTargets.forEach(input => {
|
|
138
|
+
if (input.checked === isChecked) return
|
|
139
|
+
input.checked = isChecked
|
|
140
|
+
// Programmatic .checked flips fire no event — host listeners and the
|
|
141
|
+
// form-field validation must still hear select-all (uncheck() does this).
|
|
142
|
+
input.dispatchEvent(new Event("change", { bubbles: true }))
|
|
143
|
+
})
|
|
124
144
|
this.updateTriggerContent()
|
|
125
145
|
}
|
|
126
146
|
|
|
@@ -207,7 +227,11 @@ export default class extends Controller {
|
|
|
207
227
|
e.preventDefault()
|
|
208
228
|
e.stopPropagation() // the button sits inside the trigger's click-to-open area
|
|
209
229
|
}
|
|
210
|
-
this.inputTargets.forEach(input =>
|
|
230
|
+
this.inputTargets.forEach(input => {
|
|
231
|
+
if (!input.checked) return
|
|
232
|
+
input.checked = false
|
|
233
|
+
input.dispatchEvent(new Event("change", { bubbles: true })) // see toggleAllItems
|
|
234
|
+
})
|
|
211
235
|
if (this.hasToggleAllTarget) this.toggleAllTarget.checked = false
|
|
212
236
|
this.updateTriggerContent()
|
|
213
237
|
}
|
|
@@ -223,6 +247,14 @@ export default class extends Controller {
|
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
openPopover(event) {
|
|
250
|
+
// The remove-× on a chip and the clear-all button live inside the badge
|
|
251
|
+
// trigger, whose focusin (and click) actions open the popover — clicking
|
|
252
|
+
// them must not reopen it (stopPropagation covers click but focusin still
|
|
253
|
+
// fires when the button takes focus).
|
|
254
|
+
if (event?.target?.closest?.(".pk-combobox-badge-remove, .pk-combobox-clear-button")) return
|
|
255
|
+
// closePopover's focus restore fires the input/badge wrapper's
|
|
256
|
+
// focusin->openPopover action synchronously — don't bounce straight back open.
|
|
257
|
+
if (this.refocusing) return
|
|
226
258
|
if (event) event.preventDefault()
|
|
227
259
|
if (this.isOpen()) return
|
|
228
260
|
|
|
@@ -242,12 +274,31 @@ export default class extends Controller {
|
|
|
242
274
|
}
|
|
243
275
|
|
|
244
276
|
closePopover() {
|
|
277
|
+
// Hiding the popover while focus is inside it (search input, option) drops
|
|
278
|
+
// focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
|
|
279
|
+
// only when focus would actually be orphaned — a click elsewhere on the
|
|
280
|
+
// page (onClickOutside) must not have its focus stolen.
|
|
281
|
+
const focusWasInside = this.hasPopoverTarget && this.popoverTarget.contains(document.activeElement)
|
|
282
|
+
|
|
245
283
|
this.setExpanded(false)
|
|
246
284
|
// aria-activedescendant holds an element id; on close it must be removed,
|
|
247
285
|
// not left pointing at a hidden option.
|
|
248
286
|
this.clearActiveDescendant()
|
|
249
287
|
if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
250
288
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
289
|
+
|
|
290
|
+
if (focusWasInside) {
|
|
291
|
+
const field = this.filterField()
|
|
292
|
+
// Input/badge layouts: back to the filter field (it lives outside the
|
|
293
|
+
// popover). Button layout: back to the trigger button (its search input
|
|
294
|
+
// is inside the popover, now hidden).
|
|
295
|
+
const anchor = (field && !this.popoverTarget.contains(field)) ? field : (this.hasTriggerTarget ? this.triggerTarget : null)
|
|
296
|
+
// focus() dispatches focusin synchronously; the flag stops the
|
|
297
|
+
// wrapper's focusin->openPopover action from reopening the panel.
|
|
298
|
+
this.refocusing = true
|
|
299
|
+
anchor?.focus()
|
|
300
|
+
this.refocusing = false
|
|
301
|
+
}
|
|
251
302
|
}
|
|
252
303
|
|
|
253
304
|
onClickOutside(event) {
|
|
@@ -267,7 +318,9 @@ export default class extends Controller {
|
|
|
267
318
|
}
|
|
268
319
|
|
|
269
320
|
filterItems(e) {
|
|
270
|
-
|
|
321
|
+
// Escape's keyup arrives after keydown.esc already closed the popover —
|
|
322
|
+
// re-running the filter then would wipe the highlight state for nothing.
|
|
323
|
+
if (["ArrowDown", "ArrowUp", "Tab", "Enter", "Escape"].includes(e.key)) {
|
|
271
324
|
return
|
|
272
325
|
}
|
|
273
326
|
|
|
@@ -287,6 +340,9 @@ export default class extends Controller {
|
|
|
287
340
|
|
|
288
341
|
this.selectedItemIndex = null
|
|
289
342
|
this.clearActiveDescendant()
|
|
343
|
+
// Also drop the highlight itself: a filtered-out option that kept
|
|
344
|
+
// aria-current="true" stayed the Enter target while invisible.
|
|
345
|
+
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
290
346
|
|
|
291
347
|
this.inputTargets.forEach((input) => {
|
|
292
348
|
const text = this.inputContent(input).toLowerCase()
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Labelled option group. Pass `label:`
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Labelled option group. Pass `label:` — it renders as aria-label (a bare
|
|
3
|
+
# `label` attribute is invalid on <div> and silent to AT) plus data-label,
|
|
4
|
+
# which CSS renders via ::before content(attr(data-label)). The group
|
|
5
|
+
# auto-hides (pure CSS :has) when filtering leaves it with no visible items.
|
|
6
|
+
# See combobox.rb.
|
|
5
7
|
class ComboboxListGroup < BaseComponent
|
|
6
|
-
def initialize(**attrs)
|
|
8
|
+
def initialize(label: nil, **attrs)
|
|
9
|
+
@label = label
|
|
7
10
|
@attrs = attrs
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def view_template(&)
|
|
11
|
-
div(**mix({
|
|
14
|
+
div(**mix({
|
|
15
|
+
class: "pk-combobox-group",
|
|
16
|
+
role: "group",
|
|
17
|
+
aria: { label: @label },
|
|
18
|
+
data: { label: @label }
|
|
19
|
+
}, @attrs), &)
|
|
12
20
|
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
inset: 0;
|
|
20
20
|
z-index: 50;
|
|
21
21
|
pointer-events: auto;
|
|
22
|
-
background:
|
|
22
|
+
background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
|
|
23
23
|
backdrop-filter: blur(4px);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
border-radius: .75rem;
|
|
38
38
|
background: var(--pk-surface);
|
|
39
39
|
color: var(--pk-text);
|
|
40
|
-
box-shadow: 0 10px 30px
|
|
40
|
+
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
41
41
|
}
|
|
42
42
|
.pk-command-dialog.xs { max-width: 24rem; }
|
|
43
43
|
.pk-command-dialog.sm { max-width: 28rem; }
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
.pk-command-item[data-disabled] { pointer-events: none; opacity: .5; }
|
|
122
122
|
|
|
123
123
|
.pk-command-shortcut {
|
|
124
|
-
margin-
|
|
124
|
+
margin-inline-start: auto;
|
|
125
125
|
font-size: .75rem;
|
|
126
126
|
letter-spacing: .1em;
|
|
127
127
|
color: var(--pk-muted);
|
|
@@ -136,5 +136,5 @@
|
|
|
136
136
|
font-size: .875rem;
|
|
137
137
|
}
|
|
138
138
|
@supports not (backdrop-filter: blur(1px)) {
|
|
139
|
-
.pk-command-overlay { background:
|
|
139
|
+
.pk-command-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
|
|
140
140
|
}
|
|
@@ -47,11 +47,19 @@ export default class extends Controller {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
this.generateItemIds();
|
|
50
|
-
|
|
50
|
+
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
51
|
+
// palette connecting at page load must not steal it.
|
|
52
|
+
if (this.isDialogClone()) this.inputTarget.focus();
|
|
51
53
|
this.searchIndex = this.buildSearchIndex();
|
|
52
54
|
this.toggleVisibility(this.emptyTargets, false);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// The command-dialog controller clones its <template> content into <body>;
|
|
58
|
+
// the clone's wrapper carries this marker (CommandDialogContent renders it).
|
|
59
|
+
isDialogClone() {
|
|
60
|
+
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// ARIA plumbing: every result gets an id derived from the listbox id so the
|
|
56
64
|
// input can point aria-activedescendant at the keyboard highlight, and
|
|
57
65
|
// aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
@@ -67,15 +75,37 @@ export default class extends Controller {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
dismiss() {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
79
|
+
if (this.isDialogClone()) {
|
|
80
|
+
// allow scroll on body
|
|
81
|
+
document.body.style.removeProperty("overflow");
|
|
82
|
+
const previous = this.previouslyFocused;
|
|
83
|
+
// remove the element
|
|
84
|
+
this.element.remove();
|
|
85
|
+
// restore focus to wherever it was before the palette opened
|
|
86
|
+
if (previous instanceof HTMLElement && document.contains(previous)) {
|
|
87
|
+
previous.focus();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
78
90
|
}
|
|
91
|
+
|
|
92
|
+
// Inline palette: removing the element would delete it permanently.
|
|
93
|
+
// Reset the query instead and let the host react via the dispatched
|
|
94
|
+
// phlex-kit--command:dismiss event.
|
|
95
|
+
if (this.hasInputTarget && this.inputTarget.value !== "") {
|
|
96
|
+
this.inputTarget.value = "";
|
|
97
|
+
this.deselectAll();
|
|
98
|
+
this.resetVisibility();
|
|
99
|
+
this.announceResultCount(null);
|
|
100
|
+
}
|
|
101
|
+
this.dispatch("dismiss");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Guards the items' default href="#": without it an Enter-synthesized
|
|
105
|
+
// click() or a mouse click scrolls to top and appends # to the URL
|
|
106
|
+
// (mirrors dropdown_menu_controller's close() guard).
|
|
107
|
+
onItemClick(event) {
|
|
108
|
+
if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
// Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
|
|
@@ -173,16 +203,25 @@ export default class extends Controller {
|
|
|
173
203
|
|
|
174
204
|
// Upstream builds a Fuse index here; this keeps the same search() shape —
|
|
175
205
|
// [{ item }] sorted best-match-first — using the fuzzy scorer above.
|
|
206
|
+
// Both data-value and data-text are searchable (an item's `text:` was
|
|
207
|
+
// rendered but never indexed); the better of the two scores wins.
|
|
176
208
|
buildSearchIndex() {
|
|
177
209
|
const items = this.itemTargets.map((el) => ({
|
|
178
210
|
value: (el.dataset.value || "").toLowerCase(),
|
|
211
|
+
text: (el.dataset.text || "").toLowerCase(),
|
|
179
212
|
element: el,
|
|
180
213
|
}));
|
|
181
214
|
return {
|
|
182
215
|
search(query) {
|
|
183
216
|
const q = query.toLowerCase();
|
|
184
217
|
return items
|
|
185
|
-
.map((item) =>
|
|
218
|
+
.map((item) => {
|
|
219
|
+
const scores = [item.value, item.text]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.map((field) => fuzzyScore(q, field))
|
|
222
|
+
.filter((score) => score !== null);
|
|
223
|
+
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
224
|
+
})
|
|
186
225
|
.filter((result) => result.score !== null)
|
|
187
226
|
.sort((a, b) => b.score - a.score);
|
|
188
227
|
},
|
|
@@ -225,6 +264,8 @@ export default class extends Controller {
|
|
|
225
264
|
}
|
|
226
265
|
|
|
227
266
|
this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
|
|
267
|
+
// Keep the highlight visible inside the max-height scrollable list.
|
|
268
|
+
visibleItems[this.selectedIndex].scrollIntoView({ block: "nearest" });
|
|
228
269
|
|
|
229
270
|
// Focus stays in the input; the highlighted option is exposed via
|
|
230
271
|
// aria-activedescendant (result ids come from generateItemIds).
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Wrapper wiring a CommandDialogTrigger to its CommandDialogContent. The
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# controller tracks its own live clone (sheet pattern) so a second trigger
|
|
4
|
+
# press just refocuses the input — per instance, unlike the old
|
|
5
|
+
# document-scoped outlet selector, which let one dialog adopt another's
|
|
6
|
+
# open palette. See command.rb.
|
|
5
7
|
class CommandDialog < BaseComponent
|
|
6
8
|
def initialize(**attrs)
|
|
7
9
|
@attrs = attrs
|
|
@@ -9,10 +11,7 @@ module PhlexKit
|
|
|
9
11
|
|
|
10
12
|
def view_template(&)
|
|
11
13
|
div(**mix({
|
|
12
|
-
data: {
|
|
13
|
-
controller: "phlex-kit--command-dialog",
|
|
14
|
-
phlex_kit__command_dialog_phlex_kit__command_outlet: "[data-phlex-kit--command-dialog-instance]"
|
|
15
|
-
}
|
|
14
|
+
data: { controller: "phlex-kit--command-dialog" }
|
|
16
15
|
}, @attrs), &)
|
|
17
16
|
end
|
|
18
17
|
end
|