phlex_kit 0.11.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -1
  3. data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +35 -7
  5. data/app/components/phlex_kit/attachment/attachment.css +15 -0
  6. data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -0
  7. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
  8. data/app/components/phlex_kit/calendar/calendar_controller.js +37 -9
  9. data/app/components/phlex_kit/carousel/carousel_controller.js +36 -2
  10. data/app/components/phlex_kit/chart/chart.rb +6 -4
  11. data/app/components/phlex_kit/chart/chart_controller.js +37 -8
  12. data/app/components/phlex_kit/checkbox/checkbox.rb +15 -1
  13. data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
  14. data/app/components/phlex_kit/combobox/combobox.css +1 -1
  15. data/app/components/phlex_kit/combobox/combobox_controller.js +61 -2
  16. data/app/components/phlex_kit/command/command_controller.js +77 -2
  17. data/app/components/phlex_kit/command/command_dialog_content.rb +5 -0
  18. data/app/components/phlex_kit/command/command_input.rb +3 -2
  19. data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
  20. data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
  21. data/app/components/phlex_kit/context_menu/context_menu_controller.js +75 -12
  22. data/app/components/phlex_kit/data_table/data_table.css +1 -1
  23. data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
  24. data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
  25. data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
  26. data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
  27. data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
  28. data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
  29. data/app/components/phlex_kit/date_picker/date_picker_controller.js +26 -0
  30. data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
  31. data/app/components/phlex_kit/drawer/drawer.rb +1 -1
  32. data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
  33. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
  34. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
  35. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +78 -18
  36. data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
  37. data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
  38. data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
  39. data/app/components/phlex_kit/icon/icon.rb +1 -1
  40. data/app/components/phlex_kit/input/input.css +0 -1
  41. data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
  42. data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
  43. data/app/components/phlex_kit/item/item.css +2 -1
  44. data/app/components/phlex_kit/kbd/kbd.css +2 -1
  45. data/app/components/phlex_kit/label/label.css +1 -1
  46. data/app/components/phlex_kit/link/link.rb +11 -1
  47. data/app/components/phlex_kit/marker/marker.css +2 -1
  48. data/app/components/phlex_kit/menubar/menubar.css +3 -0
  49. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  50. data/app/components/phlex_kit/menubar/menubar_controller.js +66 -16
  51. data/app/components/phlex_kit/native_select/native_select.css +14 -0
  52. data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
  53. data/app/components/phlex_kit/popover/popover_controller.js +19 -0
  54. data/app/components/phlex_kit/progress/progress.css +19 -0
  55. data/app/components/phlex_kit/progress/progress.rb +29 -13
  56. data/app/components/phlex_kit/scroll_area/scroll_area.rb +2 -15
  57. data/app/components/phlex_kit/select/select_controller.js +22 -0
  58. data/app/components/phlex_kit/select/select_trigger.rb +4 -1
  59. data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
  60. data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
  61. data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
  62. data/app/components/phlex_kit/spinner/spinner.rb +6 -3
  63. data/app/components/phlex_kit/switch/switch.rb +6 -0
  64. data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
  65. data/app/components/phlex_kit/toast/toast_action.rb +1 -1
  66. data/app/components/phlex_kit/toast/toast_close.rb +6 -3
  67. data/app/components/phlex_kit/toast/toast_controller.js +37 -16
  68. data/app/components/phlex_kit/toast/toast_item.rb +11 -7
  69. data/app/components/phlex_kit/toast/toast_region.rb +12 -3
  70. data/app/components/phlex_kit/toast/toaster_controller.js +35 -5
  71. data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
  72. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
  73. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
  74. data/app/javascript/phlex_kit/controllers/index.js +2 -0
  75. data/lib/phlex_kit/base_component.rb +22 -3
  76. data/lib/phlex_kit/version.rb +1 -1
  77. data/lib/phlex_kit.rb +1 -0
  78. metadata +2 -1
@@ -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) =>
@@ -172,6 +196,7 @@ export default class extends Controller {
172
196
  )
173
197
  return
174
198
  }
199
+ if (!this._listEl) return
175
200
  const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
176
201
  if (el) el.dispatchEvent(new CustomEvent("phlex-kit:toast:force-dismiss", { bubbles: true }))
177
202
  }
@@ -310,12 +335,14 @@ export default class extends Controller {
310
335
  api.info = (m, o) => fire("info", m, o)
311
336
  api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
312
337
  api.dismiss = (id) => {
313
- 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 } }))
314
341
  else window.dispatchEvent(new CustomEvent("phlex-kit:toast:dismiss-all"))
315
342
  }
316
343
  api.promise = (p, msgs = {}) => {
317
344
  const id = `toast-${this._uuid()}`
318
- 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 })
319
346
  Promise.resolve(p).then(
320
347
  (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
321
348
  (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
@@ -329,8 +356,11 @@ export default class extends Controller {
329
356
  }
330
357
 
331
358
  _mutate(id, variant, text) {
332
- const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
333
- 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
334
364
  el.dataset.variant = variant
335
365
  el.setAttribute("role", variant === "error" ? "alert" : "status")
336
366
  this._swapIcon(el, variant)
@@ -28,13 +28,28 @@ module PhlexKit
28
28
 
29
29
  def item_context
30
30
  { type: @type, variant: @variant, size: @size, disabled: @disabled,
31
- selected_values: selected_values, spacing: @spacing, orientation: @orientation }
31
+ selected_values: selected_values, spacing: @spacing, orientation: @orientation,
32
+ group: self }
32
33
  end
33
34
 
34
35
  def ToggleGroupItem(**kwargs, &block)
35
36
  render PhlexKit::ToggleGroupItem.new(group_context: item_context, **kwargs), &block
36
37
  end
37
38
 
39
+ # Roving-tabindex initial stop (single-type, nothing selected): the FIRST
40
+ # enabled item claims tabindex="0" server-side, mirroring what
41
+ # toggle_group_controller.js's reconcile() computes at runtime
42
+ # (`pressed || enabledItems()[0]`). Items call this once each, in render
43
+ # order, as they're constructed — a single-threaded sequential claim, not
44
+ # a lookahead over siblings not yet rendered.
45
+ def claim_tab_stop(item_disabled)
46
+ return false unless selected_values.empty?
47
+ return false if item_disabled
48
+ return false if @tab_stop_claimed
49
+ @tab_stop_claimed = true
50
+ true
51
+ end
52
+
38
53
  private
39
54
 
40
55
  def selected_values
@@ -33,14 +33,17 @@ export default class extends Controller {
33
33
  if (items.length === 0) return
34
34
  const currentIndex = items.indexOf(event.currentTarget)
35
35
  let nextIndex = currentIndex
36
- switch (event.key) {
37
- case "ArrowRight": case "ArrowDown": nextIndex = (currentIndex + 1) % items.length; break
38
- case "ArrowLeft": case "ArrowUp": nextIndex = (currentIndex - 1 + items.length) % items.length; break
39
- case "Home": nextIndex = 0; break
40
- case "End": nextIndex = items.length - 1; break
41
- case " ": case "Enter": event.preventDefault(); event.currentTarget.click(); return
42
- default: return
43
- }
36
+ // In RTL the horizontal arrows mirror (physical LEFT = next); Up/Down are
37
+ // unaffected. Runtime dir check is reliable after a dynamic flip.
38
+ const rtl = getComputedStyle(this.element).direction === "rtl"
39
+ const fwd = rtl ? "ArrowLeft" : "ArrowRight"
40
+ const back = rtl ? "ArrowRight" : "ArrowLeft"
41
+ if (event.key === fwd || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % items.length
42
+ else if (event.key === back || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + items.length) % items.length
43
+ else if (event.key === "Home") nextIndex = 0
44
+ else if (event.key === "End") nextIndex = items.length - 1
45
+ else if (event.key === " " || event.key === "Enter") { event.preventDefault(); event.currentTarget.click(); return }
46
+ else return
44
47
  event.preventDefault()
45
48
  const next = items[nextIndex]
46
49
  this.updateRovingTabindex(next)
@@ -1,19 +1,27 @@
1
1
  module PhlexKit
2
2
  class ToggleGroupItem < Toggle
3
3
  def initialize(value:, group_context:, variant: nil, size: nil, **attrs)
4
- # Toggle kwargs this item renders no markup for inheriting them
5
- # silently discarded the caller's input.
6
- unsupported = attrs.keys & %i[wrapper name unpressed_value]
4
+ # Toggle kwargs this item renders no markup for, or that the group
5
+ # derives itself (pressed: comes from the group's selected_values) —
6
+ # inheriting them silently discarded the caller's input.
7
+ unsupported = attrs.keys & %i[wrapper name unpressed_value pressed]
7
8
  if unsupported.any?
8
9
  raise ArgumentError, "ToggleGroupItem does not support #{unsupported.join(", ")} (group items render a bare button)"
9
10
  end
10
11
  @item_value = value.to_s
11
12
  @group_context = group_context
12
13
  pressed = group_context[:selected_values].include?(@item_value)
14
+ item_disabled = attrs.key?(:disabled) ? attrs.delete(:disabled) : group_context[:disabled]
15
+ # Roving tabindex initial stop: when nothing is selected yet, the first
16
+ # enabled item claims tabindex="0" (see ToggleGroup#claim_tab_stop).
17
+ # group_context[:group] is nil for a hand-built context (bypassing
18
+ # ToggleGroup#item_context) — treat that as "no tab-stop claim" rather
19
+ # than raising, since standalone tests construct contexts by hand.
20
+ @is_first_tab_stop = group_context[:group]&.claim_tab_stop(item_disabled) || false
13
21
  super(pressed: pressed, name: nil, value: @item_value,
14
22
  variant: variant || group_context[:variant],
15
23
  size: size || group_context[:size],
16
- disabled: group_context[:disabled], **attrs)
24
+ disabled: item_disabled, **attrs)
17
25
  end
18
26
 
19
27
  def view_template(&block)
@@ -34,7 +42,9 @@ module PhlexKit
34
42
  a[:aria] = { checked: @pressed.to_s }
35
43
  # A disabled button can't take focus — giving the tab stop to a
36
44
  # pressed-but-disabled item made the whole group Tab-unreachable.
37
- a[:tabindex] = (@pressed && !@disabled) ? "0" : "-1"
45
+ # When nothing is selected, the first enabled item claims the initial
46
+ # roving-tabindex stop instead of every item defaulting to -1.
47
+ a[:tabindex] = (@pressed && !@disabled) || @is_first_tab_stop ? "0" : "-1"
38
48
  else
39
49
  a[:aria] = { pressed: @pressed.to_s }
40
50
  a[:tabindex] = "0"
@@ -24,6 +24,7 @@ import ContextMenuController from "phlex_kit/controllers/context_menu_controller
24
24
  import DataTableController from "phlex_kit/controllers/data_table_controller"
25
25
  import DataTableColumnVisibilityController from "phlex_kit/controllers/data_table_column_visibility_controller"
26
26
  import DataTableSearchController from "phlex_kit/controllers/data_table_search_controller"
27
+ import DatePickerController from "phlex_kit/controllers/date_picker_controller"
27
28
  import DialogController from "phlex_kit/controllers/dialog_controller"
28
29
  import DropdownMenuController from "phlex_kit/controllers/dropdown_menu_controller"
29
30
  import FormFieldController from "phlex_kit/controllers/form_field_controller"
@@ -65,6 +66,7 @@ export function registerPhlexKitControllers(application) {
65
66
  application.register("phlex-kit--data-table", DataTableController)
66
67
  application.register("phlex-kit--data-table-column-visibility", DataTableColumnVisibilityController)
67
68
  application.register("phlex-kit--data-table-search", DataTableSearchController)
69
+ application.register("phlex-kit--date-picker", DatePickerController)
68
70
  application.register("phlex-kit--dialog", DialogController)
69
71
  application.register("phlex-kit--dropdown-menu", DropdownMenuController)
70
72
  application.register("phlex-kit--form-field", FormFieldController)
@@ -31,17 +31,36 @@ 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
37
46
  # attrs ("pagination Résultats") instead of overriding.
38
47
  def aria_labelled?
48
+ aria_key_set?(:label) || aria_key_set?(:labelledby)
49
+ end
50
+
51
+ # True when the caller already supplied their own value for a given aria
52
+ # attribute — via the aria: hash (aria: { <key>: ... }) or the flat
53
+ # "aria-<key>"/aria_<key> spelling. A component with a generated default
54
+ # for that attribute must skip it then: `mix` merges duplicate attrs
55
+ # (aria-modal="true false") instead of overriding.
56
+ def aria_key_set?(key)
39
57
  aria = @attrs[:aria] || @attrs["aria"]
40
58
  if aria.is_a?(Hash)
41
- return true if %i[label labelledby].any? { |k| aria[k] || aria[k.to_s] }
59
+ return true if aria.key?(key) || aria.key?(key.to_s)
42
60
  end
43
- [ :aria_label, "aria_label", "aria-label",
44
- :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
61
+ dashed = "aria-#{key}"
62
+ underscored = "aria_#{key}"
63
+ [ dashed.to_sym, dashed, underscored.to_sym, underscored ].any? { |k| @attrs[k] }
45
64
  end
46
65
  end
47
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.11.0"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/phlex_kit.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "json" # Carousel serializes its options value standalone (no Rails to_json)
4
4
  require "securerandom" # DatePicker generates a default input id
5
5
  require "cgi" # DataTable sort/pagination build query strings standalone
6
+ require "date" # Phlex 2.4's attribute type-dispatch references Date on every render
6
7
  # phlex-rails references ActiveSupport::SafeBuffer at require time; in a Rails
7
8
  # app it's already loaded, but a bare `require "phlex_kit"` (script/console)
8
9
  # crashes with "uninitialized constant Phlex::ActiveSupport" without these.
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.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy
@@ -306,6 +306,7 @@ files:
306
306
  - app/components/phlex_kit/data_table/data_table_toolbar.rb
307
307
  - app/components/phlex_kit/date_picker/date_picker.css
308
308
  - app/components/phlex_kit/date_picker/date_picker.rb
309
+ - app/components/phlex_kit/date_picker/date_picker_controller.js
309
310
  - app/components/phlex_kit/dialog/dialog.css
310
311
  - app/components/phlex_kit/dialog/dialog.rb
311
312
  - app/components/phlex_kit/dialog/dialog_close.rb