phlex_kit 0.12.0 → 0.13.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
  3. data/app/components/phlex_kit/calendar/calendar_controller.js +14 -3
  4. data/app/components/phlex_kit/chart/chart.rb +1 -1
  5. data/app/components/phlex_kit/checkbox/checkbox.rb +15 -1
  6. data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
  7. data/app/components/phlex_kit/combobox/combobox_controller.js +4 -0
  8. data/app/components/phlex_kit/command/command_controller.js +60 -1
  9. data/app/components/phlex_kit/command/command_dialog_content.rb +5 -0
  10. data/app/components/phlex_kit/context_menu/context_menu_controller.js +26 -0
  11. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  12. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  13. data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
  14. data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
  15. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
  16. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +24 -5
  17. data/app/components/phlex_kit/icon/icon.rb +1 -1
  18. data/app/components/phlex_kit/link/link.rb +10 -1
  19. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  20. data/app/components/phlex_kit/menubar/menubar_controller.js +26 -0
  21. data/app/components/phlex_kit/progress/progress.css +19 -0
  22. data/app/components/phlex_kit/progress/progress.rb +29 -13
  23. data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
  24. data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
  25. data/app/components/phlex_kit/switch/switch.rb +1 -1
  26. data/app/components/phlex_kit/toast/toast_controller.js +6 -1
  27. data/app/components/phlex_kit/toast/toast_item.rb +2 -2
  28. data/app/components/phlex_kit/toast/toaster_controller.js +34 -6
  29. data/lib/phlex_kit/base_component.rb +9 -0
  30. data/lib/phlex_kit/version.rb +1 -1
  31. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
4
- data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
3
+ metadata.gz: 4e3d6272f0ef818c51cc952c6a2021a1f2b6a036afdab70b617f05c64ada4144
4
+ data.tar.gz: aa43b4e8826c3dfe52d96440987195815e836de43a795f9628dfce1cbc3bcbd8
5
5
  SHA512:
6
- metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
7
- data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
6
+ metadata.gz: b655fda7c0a0b013e006b2586b5b2eec5714106a7877f4b012ed6567af45fedd0320cb8cec8360fc05288924af71e809ce56ce911d2dbb72d7fceb52adf938c2
7
+ data.tar.gz: 90070ed31d24e9f46f311345a19eaa2ef79b760a9de08cb28c174ee6126e68aa6cd4122fbe8f915d61aaf800801d1dd781b81e86fc7e4aa90595e54be8631435
@@ -30,9 +30,9 @@ module PhlexKit
30
30
  # Defaults only when the caller didn't supply their own — `mix`
31
31
  # would fuse role="alertdialog dialog" / aria-modal="true false" /
32
32
  # tabindex="-1 0" instead of overriding.
33
- panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
33
+ panel_attrs[:role] = "alertdialog" unless attr_set?(:role)
34
34
  panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
35
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
35
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
36
36
  div(**mix(panel_attrs, @attrs), &block)
37
37
  end
38
38
  end
@@ -144,9 +144,11 @@ export default class extends Controller {
144
144
  const end = this.parseDate(this.rangeEndValue);
145
145
 
146
146
  if (!start || (start && end)) {
147
- // begin a fresh range
147
+ // begin a fresh range. undefined (not null) clears the value: Stimulus
148
+ // removes the attribute for undefined, while null goes through
149
+ // setAttribute and serializes as the literal string "null".
148
150
  this.rangeStartValue = this.isoDate(candidate);
149
- this.rangeEndValue = null;
151
+ this.rangeEndValue = undefined;
150
152
  } else if (candidate < start) {
151
153
  this.rangeStartValue = this.isoDate(candidate);
152
154
  } else {
@@ -182,9 +184,10 @@ export default class extends Controller {
182
184
  return;
183
185
  }
184
186
 
185
- // update the viewDateValue to the selected date's month (this re-renders)
187
+ // update the viewDateValue to the selected date's month
186
188
  const newViewDate = new Date(selectedDate);
187
189
  newViewDate.setDate(2); // avoid month-length/timezone edges
190
+ this._viewRendered = true;
188
191
  this.viewDateValue = this.isoDate(newViewDate);
189
192
 
190
193
  this.updateCalendar();
@@ -197,6 +200,13 @@ export default class extends Controller {
197
200
  }
198
201
 
199
202
  viewDateValueChanged(value, prevValue) {
203
+ // Month-cross paths render synchronously right after writing
204
+ // viewDateValue (focus must land in the NEW grid); this async echo
205
+ // (MutationObserver) would render the same view a second time.
206
+ if (this._viewRendered) {
207
+ this._viewRendered = false;
208
+ return;
209
+ }
200
210
  this.updateCalendar();
201
211
  }
202
212
 
@@ -334,6 +344,7 @@ export default class extends Controller {
334
344
  // below would hit the OLD month, and the target day (outside it) would be
335
345
  // missing → early return, stranding focus on <body>. Drive the re-render
336
346
  // ourselves and hand it the target day so ensureGridTabStop focuses it.
347
+ this._viewRendered = true;
337
348
  this.viewDateValue = iso;
338
349
  this.updateCalendar(iso);
339
350
  return;
@@ -31,7 +31,7 @@ module PhlexKit
31
31
  # Canvas content is invisible to AT — announce it as an image with at
32
32
  # least a generic name; pass aria: { label: "…" } (or block fallback
33
33
  # content) to describe the actual data.
34
- base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
34
+ base[:role] = "img" unless attr_set?(:role)
35
35
  base[:aria] = { label: "Chart" } unless aria_labelled?
36
36
  canvas(**mix(base, @attrs), &)
37
37
  end
@@ -8,11 +8,25 @@ module PhlexKit
8
8
  # `name:`/`value:`/`checked:`/`**on(...)` pass through via `mix`. Styled by
9
9
  # `.pk-checkbox` (checkbox.css).
10
10
  class Checkbox < BaseComponent
11
- def initialize(**attrs)
11
+ # include_hidden mirrors Rails' check_box (and PhlexKit::Switch): an
12
+ # unchecked box posts nothing, so a paired hidden field carries the
13
+ # unchecked value. Emitted only when a `name:` is present — and never for
14
+ # array-style names ("ids[]"), where an unchecked "0" would inject a
15
+ # bogus element into the collection param (use a single blank hidden for
16
+ # the whole collection instead, as Rails' collection helpers do).
17
+ def initialize(include_hidden: true, unchecked_value: "0", **attrs)
18
+ @include_hidden = include_hidden
19
+ @unchecked_value = unchecked_value
12
20
  @attrs = attrs
13
21
  end
14
22
 
15
23
  def view_template
24
+ if @include_hidden && @attrs[:name] && !@attrs[:name].to_s.end_with?("[]")
25
+ # Disabled in lockstep with the checkbox (Rails' check_box idiom) —
26
+ # a disabled checkbox must not still post its unchecked value.
27
+ input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
28
+ disabled: @attrs[:disabled] ? true : nil)
29
+ end
16
30
  input(**mix({
17
31
  type: :checkbox,
18
32
  class: "pk-checkbox",
@@ -15,8 +15,8 @@ module PhlexKit
15
15
  # Defaults only when the caller didn't supply their own — `mix` would
16
16
  # fuse tabindex="0 -1" / role="region article" instead of overriding.
17
17
  base = { class: "pk-codeblock", data: { syntax: @syntax } }
18
- base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
19
- base[:role] = "region" unless @attrs.key?(:role) || @attrs.key?("role")
18
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
19
+ base[:role] = "region" unless attr_set?(:role)
20
20
  # A region landmark without a name is an axe violation — default one
21
21
  # from the syntax; a caller aria: { label: } replaces it (never fused).
22
22
  unless aria_labelled?
@@ -354,6 +354,10 @@ export default class extends Controller {
354
354
  if (!this.isOpen()) return
355
355
  if (this.element.contains(event.target)) return
356
356
 
357
+ // Kit-wide dismiss contract: the combobox is POPOVER-family (shadcn
358
+ // builds it on Popover light-dismiss), so the dismissing outside click
359
+ // deliberately clicks through — no preventDefault. Only the MENU
360
+ // overlays (dropdown/context/menubar/select) swallow theirs.
357
361
  this.closePopover()
358
362
  }
359
363
 
@@ -49,6 +49,7 @@ export default class extends Controller {
49
49
  // itemTargetConnected uses must be initialized here (mirrors
50
50
  // combobox_controller.js).
51
51
  this.itemIdCounter = 0;
52
+ this.orderCounter = 0;
52
53
  this.searchEntries = [];
53
54
  this.selectedIndex = -1;
54
55
  }
@@ -83,6 +84,13 @@ export default class extends Controller {
83
84
  return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
84
85
  }
85
86
 
87
+ // Clones are appended as direct <body> children; the last stamped clone is
88
+ // the visually topmost overlay across all clone-based overlay families.
89
+ isTopmostClone() {
90
+ const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
91
+ return clones.length > 0 && clones[clones.length - 1] === this.element;
92
+ }
93
+
86
94
  // ARIA plumbing: aria-controls is wired to the listbox (mirrors
87
95
  // select_controller.js); the per-result ids come from itemTargetConnected.
88
96
  generateItemIds() {
@@ -103,6 +111,10 @@ export default class extends Controller {
103
111
  // arrival (mirrors combobox_controller.js).
104
112
  itemTargetConnected(item) {
105
113
  if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
114
+ // Original position, stamped once — filter() reorders rows by fuzzy
115
+ // score and resetVisibility() restores this order. Survives the
116
+ // disconnect/reconnect a DOM move itself triggers.
117
+ if (!item.dataset.pkOrder) item.dataset.pkOrder = String(this.orderCounter++);
106
118
  this.searchEntries.push({
107
119
  value: (item.dataset.value || "").toLowerCase(),
108
120
  text: (item.dataset.text || "").toLowerCase(),
@@ -119,7 +131,22 @@ export default class extends Controller {
119
131
  this.selectedIndex = -1;
120
132
  }
121
133
 
122
- dismiss() {
134
+ dismiss(e) {
135
+ if (e?.type === "keydown" && this.isDialogClone()) {
136
+ // The backdrop's Escape binding is window-scoped, so it fires even
137
+ // with another overlay (alert dialog, sheet) stacked ON TOP of the
138
+ // palette — those clones are later <body> siblings carrying the same
139
+ // [data-pk-overlay-clone] marker. Only the topmost clone may act on
140
+ // Escape (mirrors alert_dialog_controller.js#topmost).
141
+ if (!this.isTopmostClone()) return;
142
+ // The input's Escape binding is element-scoped and fires BEFORE lower
143
+ // overlays' document/window listeners — stop the keydown here, or by
144
+ // the time they run this clone (and its marker) is already removed and
145
+ // their own topmost check would wrongly elect them (mirrors
146
+ // sheet_content_controller.js's Escape guard).
147
+ e.stopPropagation();
148
+ }
149
+
123
150
  // Cloned dialog overlay: tear the clone down and hand focus back.
124
151
  if (this.isDialogClone()) {
125
152
  // Dispatched BEFORE teardown: the command-dialog controller listens on
@@ -210,6 +237,9 @@ export default class extends Controller {
210
237
  results.forEach((result) =>
211
238
  this.toggleVisibility([result.item.element], true),
212
239
  );
240
+ // cmdk parity: the best match lists first. Within each parent only —
241
+ // groups keep their identity, matches never jump between them.
242
+ this.reorderWithinParents(results.map((r) => r.item.element));
213
243
 
214
244
  this.announceResultCount(results.length);
215
245
  this.toggleVisibility(this.emptyTargets, results.length === 0);
@@ -248,6 +278,35 @@ export default class extends Controller {
248
278
  this.toggleVisibility(this.groupTargets, true);
249
279
  this.toggleVisibility(this.separatorTargets, true);
250
280
  this.toggleVisibility(this.emptyTargets, false);
281
+ // Undo any fuzzy-score reordering: back to server-rendered order.
282
+ this.reorderWithinParents(
283
+ [...this.itemTargets].sort((a, b) => Number(a.dataset.pkOrder) - Number(b.dataset.pkOrder)),
284
+ );
285
+ }
286
+
287
+ // Re-inserts `orderedItems` in the given sequence, each within its own
288
+ // parent, at the position of that parent's first item row — headings,
289
+ // separators and empty-state siblings keep their places. (Items are
290
+ // assumed contiguous within a parent; a separator BETWEEN items of one
291
+ // parent would drift below them.) The moves retrigger item target
292
+ // disconnect/connect, which is safe: ids and pkOrder are stamped once,
293
+ // and filter()/dismiss() have already reset the highlight.
294
+ reorderWithinParents(orderedItems) {
295
+ const itemSet = new Set(this.itemTargets);
296
+ const byParent = new Map();
297
+ orderedItems.forEach((el) => {
298
+ const parent = el.parentElement;
299
+ if (!parent) return;
300
+ if (!byParent.has(parent)) byParent.set(parent, []);
301
+ byParent.get(parent).push(el);
302
+ });
303
+ byParent.forEach((els, parent) => {
304
+ const firstItem = [...parent.children].find((c) => itemSet.has(c));
305
+ const placeholder = document.createComment("");
306
+ parent.insertBefore(placeholder, firstItem || null);
307
+ els.forEach((el) => parent.insertBefore(el, placeholder));
308
+ placeholder.remove();
309
+ });
251
310
  }
252
311
 
253
312
  // Upstream builds a Fuse index here; this keeps the same result shape —
@@ -17,9 +17,14 @@ module PhlexKit
17
17
  template(data: { phlex_kit__command_dialog_target: "content" }) do
18
18
  # The keydown action is the focus trap: Tab cycles within the cloned
19
19
  # overlay instead of escaping to the page underneath.
20
+ # data-pk-overlay-clone is the common marker stamped by every
21
+ # clone-based overlay family (see alert_dialog_controller.js#topmost):
22
+ # it lets stacked overlays of DIFFERENT types resolve which one is
23
+ # topmost, so one Escape never closes both layers at once.
20
24
  div(data: {
21
25
  controller: "phlex-kit--command",
22
26
  phlex_kit__command_dialog_instance: true,
27
+ pk_overlay_clone: "",
23
28
  action: "keydown->phlex-kit--command#trapFocus"
24
29
  }) do
25
30
  backdrop
@@ -8,6 +8,18 @@ import { Controller } from "@hotwired/stimulus"
8
8
  // native [popover=manual] in the top layer (context_menu.css); it is placed
9
9
  // in viewport coordinates after showPopover() — a hidden popover has no
10
10
  // size — and clamped so it never overflows the viewport (no floating-ui).
11
+
12
+ // One-shot capture listener swallowing the click an outside mousedown is
13
+ // about to produce (see onDocMousedown; same helper in dropdown/menubar —
14
+ // duplicated per controller by design). {once} self-removes; the DOM
15
+ // dedupes re-arming with the same fn. If the gesture's click never fires
16
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
17
+ // one — rare enough to accept.
18
+ const swallowClick = (ev) => ev.preventDefault()
19
+ function armSwallowClick() {
20
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
21
+ }
22
+
11
23
  export default class extends Controller {
12
24
  static targets = ["content", "trigger", "menuItem"]
13
25
 
@@ -22,6 +34,17 @@ export default class extends Controller {
22
34
  if (e.type === "contextmenu" && this.element.contains(e.target)) return
23
35
  this.close()
24
36
  }
37
+ // Kit-wide dismiss contract (Radix modal-menu parity): a plain click
38
+ // outside only dismisses — swallowed, like dropdown/menubar/select.
39
+ // Armed at MOUSEDOWN: the gesture's focusout close() removes onDoc
40
+ // before the click even fires. Right-button mousedowns are ignored so a
41
+ // contextmenu elsewhere stays click-through (that's how a second kit —
42
+ // or the native — context menu opens in the same gesture).
43
+ this.onDocMousedown = (e) => {
44
+ if (this.contentTarget.contains(e.target)) return
45
+ if (e.button !== 0) return
46
+ armSwallowClick()
47
+ }
25
48
  this.onKey = (e) => this.keydown(e)
26
49
  // Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
27
50
  // stale data-state="open" (the panel itself can't resurrect, but host
@@ -47,6 +70,7 @@ export default class extends Controller {
47
70
  }
48
71
 
49
72
  disconnect() {
73
+ document.removeEventListener("mousedown", this.onDocMousedown)
50
74
  document.removeEventListener("click", this.onDoc)
51
75
  document.removeEventListener("contextmenu", this.onDoc)
52
76
  document.removeEventListener("keydown", this.onKey)
@@ -95,6 +119,7 @@ export default class extends Controller {
95
119
  c.style.left = `${x}px`
96
120
  c.style.top = `${y}px`
97
121
  c.dataset.state = "open"
122
+ document.addEventListener("mousedown", this.onDocMousedown)
98
123
  document.addEventListener("click", this.onDoc)
99
124
  document.addEventListener("contextmenu", this.onDoc)
100
125
  document.addEventListener("keydown", this.onKey)
@@ -109,6 +134,7 @@ export default class extends Controller {
109
134
  if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
110
135
  if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
111
136
  this.contentTarget.dataset.state = "closed"
137
+ document.removeEventListener("mousedown", this.onDocMousedown)
112
138
  document.removeEventListener("click", this.onDoc)
113
139
  document.removeEventListener("contextmenu", this.onDoc)
114
140
  document.removeEventListener("keydown", this.onKey)
@@ -66,7 +66,13 @@ export default class extends Controller {
66
66
  }
67
67
 
68
68
  if (this.hasSelectionSummaryTarget) {
69
- this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
69
+ // The component stamps its (possibly localized) %{selected}/%{total}
70
+ // template as data-format; live updates interpolate the same string
71
+ // the server rendered.
72
+ const format = this.selectionSummaryTarget.dataset.format || "%{selected} of %{total} row(s) selected.";
73
+ this.selectionSummaryTarget.textContent = format
74
+ .replace("%{selected}", String(selected))
75
+ .replace("%{total}", String(total));
70
76
  }
71
77
 
72
78
  if (this.hasBulkActionsTarget) {
@@ -1,9 +1,15 @@
1
1
  module PhlexKit
2
2
  # "N of M row(s) selected." line, kept current by the controller.
3
+ # `format:` localizes it — a template with %{selected} and %{total}
4
+ # placeholders, used for the server render AND stamped on the element so
5
+ # the controller's live updates interpolate the same string.
3
6
  # See data_table.rb.
4
7
  class DataTableSelectionSummary < BaseComponent
5
- def initialize(total_on_page: 0, **attrs)
8
+ DEFAULT_FORMAT = "%{selected} of %{total} row(s) selected."
9
+
10
+ def initialize(total_on_page: 0, format: DEFAULT_FORMAT, **attrs)
6
11
  @total_on_page = total_on_page
12
+ @format = format
7
13
  @attrs = attrs
8
14
  end
9
15
 
@@ -11,9 +17,12 @@ module PhlexKit
11
17
  div(**mix({
12
18
  class: "pk-data-table-selection-summary",
13
19
  role: "status", # live region — selection changes are announced to AT
14
- data: { phlex_kit__data_table_target: "selectionSummary" }
20
+ data: {
21
+ phlex_kit__data_table_target: "selectionSummary",
22
+ format: @format
23
+ }
15
24
  }, @attrs)) do
16
- plain "0 of #{@total_on_page} row(s) selected."
25
+ plain Kernel.format(@format, selected: 0, total: @total_on_page)
17
26
  end
18
27
  end
19
28
  end
@@ -13,11 +13,9 @@ export default class extends Controller {
13
13
  onCalendarChange(e) {
14
14
  const { mode, rangeEnd } = e.detail
15
15
  if (mode === "multiple") return
16
- // calendar_controller.js clears rangeEnd via `this.rangeEndValue = null`,
17
- // which goes through Element#setAttribute the DOM stringifies a null
18
- // argument to the literal "null", so a genuinely-unset rangeEnd reads
19
- // back as that string, not JS null/undefined/"".
20
- if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
16
+ // calendar_controller.js clears rangeEnd by assigning undefined (Stimulus
17
+ // removes the attribute), so an unset rangeEnd reads back as null here.
18
+ if (mode === "range" && !rangeEnd) return
21
19
  this.popoverController()?.close()
22
20
  }
23
21
 
@@ -18,9 +18,9 @@ module PhlexKit
18
18
  # Defaults only when the caller didn't supply their own — `mix`
19
19
  # would fuse role="dialog region" / aria-modal="true false" /
20
20
  # tabindex="-1 0" instead of overriding.
21
- panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
21
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
22
22
  panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
24
24
  div(**mix(panel_attrs, @attrs)) do
25
25
  div(class: "pk-drawer-handle", aria: { hidden: "true" })
26
26
  yield if block
@@ -20,7 +20,9 @@ module PhlexKit
20
20
  phlex_kit__dropdown_menu_open_value: @open,
21
21
  # focusout: tabbing (or otherwise moving real focus) out of the
22
22
  # menu closes the open [popover=manual] panel (menubar's pattern).
23
- action: "click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
23
+ # mousedown@window arms the modal-menu click swallow before the
24
+ # gesture's focusout closes the panel (see the controller).
25
+ action: "mousedown@window->phlex-kit--dropdown-menu#onMousedownOutside click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
24
26
  }
25
27
  }, @attrs), &block)
26
28
  end
@@ -10,6 +10,18 @@ import { Controller } from "@hotwired/stimulus";
10
10
  // sub-rows are skipped via a visibility filter. Escape returns focus to the
11
11
  // trigger; Enter/Space activate the focused row (links, checkbox/radio
12
12
  // labels, and sub triggers alike).
13
+
14
+ // One-shot capture listener swallowing the click an outside mousedown is
15
+ // about to produce (see onMousedownOutside; same helper in menubar/context
16
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
17
+ // dedupes re-arming with the same fn. If the gesture's click never fires
18
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
19
+ // one — rare enough to accept.
20
+ const swallowClick = (ev) => ev.preventDefault();
21
+ function armSwallowClick() {
22
+ window.addEventListener("click", swallowClick, { once: true, capture: true });
23
+ }
24
+
13
25
  export default class extends Controller {
14
26
  static targets = ["trigger", "content", "menuItem"];
15
27
  static values = { open: { type: Boolean, default: false } };
@@ -69,16 +81,23 @@ export default class extends Controller {
69
81
  this.subFrames.add(id);
70
82
  }
71
83
 
84
+ // Deliberate (matches Radix's modal dismiss): the dismissing outside
85
+ // click ONLY dismisses the menu — swallowed rather than also acting on
86
+ // whatever was under the pointer (e.g. navigating a link). Armed at
87
+ // MOUSEDOWN: for a focusable click target the same gesture's focusout
88
+ // closes the panel before the click fires, so a click-time swallow
89
+ // silently skipped those targets (audit round 8).
90
+ onMousedownOutside(event) {
91
+ if (!this.contentTarget.matches(":popover-open")) return;
92
+ if (this.element.contains(event.target)) return;
93
+ armSwallowClick();
94
+ }
95
+
72
96
  onClickOutside(event) {
73
97
  // Gate on the live :popover-open state, not the stored flag — a stale
74
98
  // flag is how a close on an already-closed panel becomes an open.
75
99
  if (!this.contentTarget.matches(":popover-open")) return;
76
100
  if (this.element.contains(event.target)) return;
77
-
78
- // Deliberate (matches Radix's modal dismiss): the outside click ONLY
79
- // dismisses the menu — it is swallowed rather than also acting on
80
- // whatever was under the pointer (e.g. navigating a link).
81
- event.preventDefault();
82
101
  this.close();
83
102
  }
84
103
 
@@ -24,7 +24,7 @@ module PhlexKit
24
24
  # `mix` merges duplicate string attrs ("16 24") — drop a generated attr
25
25
  # whenever the caller supplies their own copy, so theirs wins.
26
26
  %i[width height viewbox aria-hidden].each do |key|
27
- base.delete(key) if @attrs.key?(key) || @attrs.key?(key.to_s)
27
+ base.delete(key) if attr_set?(key)
28
28
  end
29
29
  svg(**mix(base, @attrs)) do |s|
30
30
  icon[:elements].each { |tag, attrs| s.public_send(tag, **attrs) }
@@ -36,11 +36,20 @@ module PhlexKit
36
36
  end
37
37
 
38
38
  def view_template(&block)
39
- a(**mix({ href: @href, class: classes }, @attrs), &block)
39
+ base = { href: @href, class: classes }
40
+ # target="_blank" hands the new page a window.opener reference in older
41
+ # browsers (modern ones imply noopener) — default the safe rel unless
42
+ # the caller supplied their own (`mix` would fuse "noopener external").
43
+ base[:rel] = "noopener" if blank_target? && !attr_set?(:rel)
44
+ a(**mix(base, @attrs), &block)
40
45
  end
41
46
 
42
47
  private
43
48
 
49
+ def blank_target?
50
+ (@attrs[:target] || @attrs["target"]).to_s == "_blank"
51
+ end
52
+
44
53
  def classes
45
54
  [ "pk-button", fetch_option(VARIANTS, @variant, :variant), fetch_option(SIZES, @size, :size), ("icon" if @icon) ].compact.join(" ")
46
55
  end
@@ -18,7 +18,9 @@ module PhlexKit
18
18
  controller: "phlex-kit--menubar",
19
19
  # focusout: tabbing out of the bar closes the open [popover=manual]
20
20
  # panel (which no focus trap holds open).
21
- action: "click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
21
+ # mousedown@window arms the modal-menu click swallow (deliberately
22
+ # NOT bound by navigation_menu.rb — nav menus are non-modal).
23
+ action: "mousedown@window->phlex-kit--menubar#onMousedownOutside click@window->phlex-kit--menubar#onClickOutside keydown->phlex-kit--menubar#onKeydown focusout->phlex-kit--menubar#onFocusout"
22
24
  }
23
25
  }, @attrs), &)
24
26
  end
@@ -13,6 +13,17 @@ import { Controller } from "@hotwired/stimulus"
13
13
  // Connects to data-controller="phlex-kit--menubar"
14
14
  let uid = 0
15
15
 
16
+ // One-shot capture listener swallowing the click an outside mousedown is
17
+ // about to produce (see onMousedownOutside; same helper in dropdown/context
18
+ // menu — duplicated per controller by design). {once} self-removes; the DOM
19
+ // dedupes re-arming with the same fn. If the gesture's click never fires
20
+ // (e.g. suppressed after a selection drag) the armed swallow eats the next
21
+ // one — rare enough to accept.
22
+ const swallowClick = (ev) => ev.preventDefault()
23
+ function armSwallowClick() {
24
+ window.addEventListener("click", swallowClick, { once: true, capture: true })
25
+ }
26
+
16
27
  export default class extends Controller {
17
28
  static targets = ["menu"]
18
29
 
@@ -131,6 +142,21 @@ export default class extends Controller {
131
142
  if (opts.refocus === true) trigger?.focus()
132
143
  }
133
144
 
145
+ // Kit-wide dismiss contract (Radix parity): MENU overlays (dropdown,
146
+ // context, menubar, select) are modal — the dismissing outside click is
147
+ // swallowed rather than also acting on what was under the pointer. The
148
+ // decision happens at MOUSEDOWN: the same gesture's focusout closes the
149
+ // panel before the click event fires, so a click-time openMenu check
150
+ // would already be null (that race made the old swallow silently skip
151
+ // focusable click targets). Only menubar.rb binds this action — the
152
+ // navigation menu (hover-open mode) is non-modal like Radix's and its
153
+ // dismissing click clicks through.
154
+ onMousedownOutside(e) {
155
+ if (this.element.contains(e.target)) return
156
+ if (!this.openMenu) return
157
+ armSwallowClick()
158
+ }
159
+
134
160
  onClickOutside(e) {
135
161
  if (this.element.contains(e.target)) return
136
162
  this.close()
@@ -23,3 +23,22 @@
23
23
  .pk-progress:dir(rtl) .pk-progress-indicator {
24
24
  transform: translateX(calc((100 - var(--pk-progress-value, 0)) * 1%));
25
25
  }
26
+
27
+ /* Indeterminate (value: nil): a 40%-wide segment sweeps the track.
28
+ translateX % is relative to the segment's own width: -100% parks it just
29
+ off the track's start edge, +250% (track = 250% of segment) just past the
30
+ end. Direction-agnostic — the sweep reads fine mirrored, no RTL arm. */
31
+ .pk-progress.indeterminate .pk-progress-indicator {
32
+ width: 40%;
33
+ transition: none;
34
+ animation: pk-progress-indeterminate 1.5s ease-in-out infinite;
35
+ }
36
+ @keyframes pk-progress-indeterminate {
37
+ from { transform: translateX(-100%); }
38
+ to { transform: translateX(250%); }
39
+ }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .pk-progress.indeterminate .pk-progress-indicator {
42
+ animation-duration: 3s;
43
+ }
44
+ }
@@ -6,29 +6,45 @@ module PhlexKit
6
6
  class Progress < BaseComponent
7
7
  # value_text: is a named kwarg (not read from **attrs) because `mix`
8
8
  # would *fuse* a caller "aria-valuetext" with the generated default.
9
+ # `value: nil` renders the INDETERMINATE state (Radix parity): a sliding
10
+ # animation, data-state="indeterminate", and no aria-valuenow — which is
11
+ # exactly how ARIA marks a progressbar of unknown value.
9
12
  def initialize(value: 0, value_text: nil, **attrs)
10
- @value = value.to_f.clamp(0.0, 100.0)
13
+ @value = value.nil? ? nil : value.to_f.clamp(0.0, 100.0)
11
14
  @value_text = value_text
12
15
  @attrs = attrs
13
16
  end
14
17
 
15
18
  def view_template
16
- # %g drops float artifacts ("40.0" would be announced as "forty point
17
- # zero percent"). The value travels as a custom property so the fill
18
- # transform lives in progress.css, where a :dir(rtl) arm can flip it —
19
- # an inline translateX always filled from the physical left.
20
- v = Kernel.format("%g", @value)
21
- div(**mix({
19
+ div(**mix(root_attrs, @attrs)) do
20
+ div(class: "pk-progress-indicator")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def root_attrs
27
+ base = {
22
28
  role: "progressbar",
23
- "aria-valuenow": v,
24
29
  "aria-valuemin": 0,
25
30
  "aria-valuemax": 100,
26
- "aria-valuetext": @value_text || "#{v}%",
27
- class: "pk-progress",
28
- style: "--pk-progress-value: #{v};"
29
- }, @attrs)) do
30
- div(class: "pk-progress-indicator")
31
+ class: "pk-progress"
32
+ }
33
+ if @value.nil?
34
+ base[:class] = "pk-progress indeterminate"
35
+ base[:data] = { state: "indeterminate" }
36
+ base[:"aria-valuetext"] = @value_text if @value_text
37
+ else
38
+ # %g drops float artifacts ("40.0" would be announced as "forty point
39
+ # zero percent"). The value travels as a custom property so the fill
40
+ # transform lives in progress.css, where a :dir(rtl) arm can flip it —
41
+ # an inline translateX always filled from the physical left.
42
+ v = Kernel.format("%g", @value)
43
+ base[:"aria-valuenow"] = v
44
+ base[:"aria-valuetext"] = @value_text || "#{v}%"
45
+ base[:style] = "--pk-progress-value: #{v};"
31
46
  end
47
+ base
32
48
  end
33
49
  end
34
50
  end
@@ -15,7 +15,7 @@ module PhlexKit
15
15
  # Defaults only when the caller didn't supply their own — `mix` would
16
16
  # fuse role="region log" / tabindex="0 -1" instead of overriding.
17
17
  base = { class: "pk-scroll-area" }
18
- base[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
18
+ base[:tabindex] = "0" unless attr_set?(:tabindex)
19
19
  base[:role] = "region" if aria_labelled? && !@attrs.key?(:role) && !@attrs.key?("role")
20
20
  div(**mix(base, @attrs), &)
21
21
  end
@@ -18,9 +18,9 @@ module PhlexKit
18
18
  # Defaults only when the caller didn't supply their own — `mix`
19
19
  # would fuse role="dialog region" / aria-modal="true false" /
20
20
  # tabindex="-1 0" instead of overriding.
21
- panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
21
+ panel_attrs[:role] = "dialog" unless attr_set?(:role)
22
22
  panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
23
- panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
23
+ panel_attrs[:tabindex] = "-1" unless attr_set?(:tabindex)
24
24
  div(**mix(panel_attrs, @attrs)) do
25
25
  block&.call
26
26
  if @show_close_button
@@ -9,7 +9,7 @@ module PhlexKit
9
9
  # value: is silently clobbered below (the checkbox's real value comes
10
10
  # from checked_value:) — fail loud rather than let a caller's value:
11
11
  # vanish (mirrors ToggleGroupItem's unsupported-kwargs guard).
12
- if attrs.key?(:value)
12
+ if attrs.key?(:value) || attrs.key?("value")
13
13
  raise ArgumentError, "Switch does not support value: — the checkbox's value comes from checked_value:, pass that instead"
14
14
  end
15
15
  @include_hidden = include_hidden
@@ -208,7 +208,12 @@ export default class extends Controller {
208
208
  this.element.dataset.swipe = "cancel"
209
209
  this.element.style.removeProperty("--pk-toast-swipe-x")
210
210
  this.element.style.removeProperty("--pk-toast-swipe-y")
211
- this._resume("hover")
211
+ // pointerleave is suppressed while a swipe is active, so release the
212
+ // hover hold here — but only if the pointer actually left the toast; a
213
+ // cancelled swipe ending under the cursor must stay hover-paused (the
214
+ // eventual pointerleave resumes it, including the touch case where
215
+ // pointerleave fires right after pointerup).
216
+ if (!this.element.matches(":hover")) this._resume("hover")
212
217
  }
213
218
  }
214
219
  }
@@ -60,10 +60,10 @@ module PhlexKit
60
60
  # Defaults only when the caller didn't supply their own — `mix` would
61
61
  # fuse role="status log" / tabindex="0 -1" / aria-atomic="true false"
62
62
  # instead of overriding.
63
- unless @attrs.key?(:role) || @attrs.key?("role")
63
+ unless attr_set?(:role)
64
64
  attrs[:role] = ALERT_VARIANTS.include?(@variant) ? "alert" : "status"
65
65
  end
66
- attrs[:tabindex] = "0" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
66
+ attrs[:tabindex] = "0" unless attr_set?(:tabindex)
67
67
  unless aria_key_set?(:atomic)
68
68
  attrs[:aria] = { atomic: "true" }
69
69
  end
@@ -63,12 +63,26 @@ export default class extends Controller {
63
63
  // _setExpanded's state change left auto-dismiss running under the cursor.
64
64
  this._onPointerEnter = () => { this._setPaused(true); this._setExpanded(true) }
65
65
  this._onPointerLeave = () => { this._setPaused(false); if (!this.expandValue) this._setExpanded(false) }
66
- this._onWindowToast = (e) => this._spawn(e.detail || {})
66
+ this._onWindowToast = (e) => {
67
+ const detail = e.detail || {}
68
+ // Routing: toast(msg, { region: "<id>" }) targets the region whose
69
+ // wrapper or list carries that id. Without region only the page's
70
+ // FIRST region spawns — the event is a window broadcast, so every
71
+ // region hears it and an unguarded spawn would duplicate the toast
72
+ // once per region.
73
+ if (!this._handlesRegion(detail.region)) return
74
+ this._spawn(detail)
75
+ }
67
76
  this._onWindowDismissAll = () => this._dismissById(null)
77
+ // Dismiss-by-id is a broadcast too: _dismissById only acts on a toast in
78
+ // its OWN list, so whichever region owns the id handles it — the api
79
+ // registration (last region wins) no longer has to be the owner.
80
+ this._onWindowDismissId = (e) => this._dismissById(e.detail?.id)
68
81
  this._onKey = this._onKey.bind(this)
69
82
 
70
83
  window.addEventListener("phlex-kit:toast", this._onWindowToast)
71
84
  window.addEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
85
+ window.addEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
72
86
  this._listEl.addEventListener("pointerenter", this._onPointerEnter)
73
87
  this._listEl.addEventListener("pointerleave", this._onPointerLeave)
74
88
  document.addEventListener("keydown", this._onKey)
@@ -81,6 +95,7 @@ export default class extends Controller {
81
95
  disconnect() {
82
96
  window.removeEventListener("phlex-kit:toast", this._onWindowToast)
83
97
  window.removeEventListener("phlex-kit:toast:dismiss-all", this._onWindowDismissAll)
98
+ window.removeEventListener("phlex-kit:toast:dismiss", this._onWindowDismissId)
84
99
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
85
100
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
86
101
  document.removeEventListener("keydown", this._onKey)
@@ -165,6 +180,15 @@ export default class extends Controller {
165
180
  return node.id
166
181
  }
167
182
 
183
+ // True when this region should act on a toast event carrying `region`
184
+ // (the list's base id or the wrapper's "<id>-region" both match). Without
185
+ // an explicit region, only the page's first region in DOM order handles
186
+ // the broadcast — single-region pages are unaffected.
187
+ _handlesRegion(region) {
188
+ if (region) return region === this.element.id || region === this._listEl?.id
189
+ return this.element === document.querySelector("[data-controller~='phlex-kit--toaster']")
190
+ }
191
+
168
192
  _dismissById(id) {
169
193
  if (!id) {
170
194
  this.toastTargets.forEach((el) =>
@@ -311,12 +335,14 @@ export default class extends Controller {
311
335
  api.info = (m, o) => fire("info", m, o)
312
336
  api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
313
337
  api.dismiss = (id) => {
314
- if (id) this._dismissById(id)
338
+ // Both broadcasts: whichever region owns the id dismisses it — the
339
+ // api may be registered by a different region than the owner.
340
+ if (id) window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss", { detail: { id } }))
315
341
  else window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss-all"))
316
342
  }
317
343
  api.promise = (p, msgs = {}) => {
318
344
  const id = `toast-${this._uuid()}`
319
- fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
345
+ fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0, region: msgs.region })
320
346
  Promise.resolve(p).then(
321
347
  (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
322
348
  (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
@@ -330,9 +356,11 @@ export default class extends Controller {
330
356
  }
331
357
 
332
358
  _mutate(id, variant, text) {
333
- if (!this._listEl) return
334
- const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
335
- if (!el) return
359
+ // Document-scoped: promise()'s loading toast spawns in whichever region
360
+ // the broadcast routed it to, which need not be the region that
361
+ // registered the api this closure belongs to.
362
+ const el = document.getElementById(id)
363
+ if (!el || !el.closest(".pk-toast-list")) return
336
364
  el.dataset.variant = variant
337
365
  el.setAttribute("role", variant === "error" ? "alert" : "status")
338
366
  this._swapIcon(el, variant)
@@ -31,6 +31,15 @@ module PhlexKit
31
31
  end
32
32
  end
33
33
 
34
+ # True when the caller supplied their own value for a plain attribute in
35
+ # @attrs — symbol or string spelling. A component with a generated default
36
+ # for that attribute must skip it then: `mix` merges duplicate attrs
37
+ # (role="dialog region") instead of overriding. Aria attributes have their
38
+ # own check (aria_key_set?) covering the aria: hash spelling too.
39
+ def attr_set?(key)
40
+ @attrs.key?(key) || @attrs.key?(key.to_s)
41
+ end
42
+
34
43
  # True when the caller supplied an accessible name in @attrs — via the
35
44
  # aria: hash or flat aria_label/aria-label(ledby) keys. Components with a
36
45
  # generated default label must skip it then: `mix` merges duplicate string
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy