phlex_kit 0.9.1 → 0.11.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
  4. data/app/components/phlex_kit/accordion/accordion.css +7 -0
  5. data/app/components/phlex_kit/alert/alert.rb +1 -1
  6. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
  7. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  8. data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
  9. data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
  10. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  11. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  12. data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
  13. data/app/components/phlex_kit/badge/badge.css +1 -1
  14. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  15. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  16. data/app/components/phlex_kit/bubble/bubble.rb +4 -6
  17. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  18. data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
  19. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  20. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  21. data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
  22. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  23. data/app/components/phlex_kit/carousel/carousel.rb +3 -2
  24. data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
  25. data/app/components/phlex_kit/chart/chart.rb +8 -2
  26. data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
  27. data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
  28. data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
  29. data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
  30. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  31. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  32. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  33. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  34. data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
  35. data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
  36. data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
  37. data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
  38. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  39. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
  40. data/app/components/phlex_kit/command/command.css +2 -2
  41. data/app/components/phlex_kit/command/command_controller.js +63 -35
  42. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  43. data/app/components/phlex_kit/command/command_group.rb +5 -2
  44. data/app/components/phlex_kit/command/command_item.rb +4 -0
  45. data/app/components/phlex_kit/command/command_list.rb +6 -2
  46. data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
  47. data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
  48. data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
  49. data/app/components/phlex_kit/data_table/data_table.css +0 -10
  50. data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
  51. data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
  52. data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
  53. data/app/components/phlex_kit/dialog/dialog.css +5 -3
  54. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  55. data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
  56. data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
  57. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  58. data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
  59. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
  60. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
  61. data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
  62. data/app/components/phlex_kit/empty/empty.css +1 -1
  63. data/app/components/phlex_kit/field/field.css +2 -1
  64. data/app/components/phlex_kit/field/field_separator.rb +1 -1
  65. data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
  66. data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
  67. data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
  68. data/app/components/phlex_kit/icon/icon.css +9 -0
  69. data/app/components/phlex_kit/icon/icon.rb +7 -1
  70. data/app/components/phlex_kit/input_group/input_group.css +0 -1
  71. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  72. data/app/components/phlex_kit/marker/marker.rb +15 -2
  73. data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
  74. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  75. data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
  76. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  77. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  78. data/app/components/phlex_kit/message/message.rb +5 -0
  79. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
  80. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  81. data/app/components/phlex_kit/native_select/native_select.rb +12 -3
  82. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
  83. data/app/components/phlex_kit/pagination/pagination.rb +10 -2
  84. data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
  85. data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
  86. data/app/components/phlex_kit/popover/popover_controller.js +31 -1
  87. data/app/components/phlex_kit/progress/progress.css +8 -0
  88. data/app/components/phlex_kit/progress/progress.rb +14 -5
  89. data/app/components/phlex_kit/resizable/resizable_controller.js +52 -5
  90. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  91. data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
  92. data/app/components/phlex_kit/select/select.css +5 -3
  93. data/app/components/phlex_kit/select/select_content.rb +9 -3
  94. data/app/components/phlex_kit/select/select_controller.js +59 -6
  95. data/app/components/phlex_kit/select/select_item.rb +5 -1
  96. data/app/components/phlex_kit/separator/separator.rb +12 -2
  97. data/app/components/phlex_kit/sheet/sheet.css +4 -2
  98. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  99. data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
  100. data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
  101. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  102. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  103. data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
  104. data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
  105. data/app/components/phlex_kit/slider/slider.css +15 -0
  106. data/app/components/phlex_kit/switch/switch.css +5 -0
  107. data/app/components/phlex_kit/table/table.css +2 -1
  108. data/app/components/phlex_kit/table/table.rb +5 -2
  109. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  110. data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
  111. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  112. data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
  113. data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
  114. data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
  115. data/app/components/phlex_kit/toast/toast.css +3 -2
  116. data/app/components/phlex_kit/toast/toast_action.rb +4 -1
  117. data/app/components/phlex_kit/toast/toast_controller.js +13 -5
  118. data/app/components/phlex_kit/toast/toast_region.rb +19 -9
  119. data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
  120. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  121. data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
  122. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
  123. data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
  124. data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
  125. data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
  126. data/app/components/phlex_kit/typography/heading.rb +13 -5
  127. data/app/components/phlex_kit/typography/list.rb +22 -0
  128. data/app/components/phlex_kit/typography/text.rb +7 -1
  129. data/lib/phlex_kit/base_component.rb +13 -0
  130. data/lib/phlex_kit/version.rb +1 -1
  131. metadata +3 -1
@@ -41,11 +41,22 @@ module PhlexKit
41
41
  @duration = duration
42
42
  @gap = gap
43
43
  @offset = offset
44
+ # theme:/rich_colors: are Sonner API surface the kit deliberately does
45
+ # not implement (tokens are :root-scoped; there is no status palette) —
46
+ # fail loud rather than silently no-op (audit round 6).
44
47
  @theme = theme.to_sym
45
- @rich_colors = rich_colors
48
+ unless @theme == :system
49
+ raise ArgumentError, "ToastRegion theme: only :system is supported — toasts follow the page theme (:root[data-theme])"
50
+ end
51
+ if rich_colors
52
+ raise ArgumentError, "ToastRegion rich_colors: is not supported — the kit has no status color palette (toasts follow --pk-* tokens)"
53
+ end
46
54
  @close_button = close_button
47
55
  @hotkey = hotkey
48
- @dir = dir
56
+ @dir = dir.to_sym
57
+ unless %i[ltr rtl auto].include?(@dir)
58
+ raise ArgumentError, "ToastRegion dir: must be :ltr, :rtl or :auto"
59
+ end
49
60
  @flash = flash
50
61
  # Base id for the <ol> stack; the wrapper takes "#{id}-region". Defaults
51
62
  # keep the historical "pk-toaster"/"pk-toaster-region" pair — pass a
@@ -105,10 +116,12 @@ module PhlexKit
105
116
  end
106
117
 
107
118
  def region_attrs
108
- {
119
+ base = {
109
120
  id: "#{@id}-region",
110
121
  role: "region",
111
122
  class: "pk-toast-region",
123
+ # offset: viewport-edge padding, consumed by toast.css.
124
+ style: "--pk-toast-offset: #{@offset.is_a?(Numeric) ? "#{@offset}px" : @offset};",
112
125
  # No aria-live here: each toast item is its own live region
113
126
  # (role="status"/"alert"); a live wrapper would double-announce.
114
127
  aria: { label: "Notifications" },
@@ -122,14 +135,11 @@ module PhlexKit
122
135
  phlex_kit__toaster_max_value: @max.to_s,
123
136
  phlex_kit__toaster_duration_value: @duration.to_s,
124
137
  phlex_kit__toaster_gap_value: @gap.to_s,
125
- phlex_kit__toaster_offset_value: @offset.to_s,
126
- phlex_kit__toaster_theme_value: @theme.to_s,
127
- phlex_kit__toaster_rich_colors_value: @rich_colors.to_s,
128
- phlex_kit__toaster_close_button_value: @close_button.to_s,
129
- phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+"),
130
- phlex_kit__toaster_dir_value: @dir.to_s
138
+ phlex_kit__toaster_hotkey_value: Array(@hotkey).join("+")
131
139
  }
132
140
  }
141
+ base[:dir] = @dir.to_s unless @dir == :ltr
142
+ base
133
143
  end
134
144
  end
135
145
  end
@@ -34,12 +34,10 @@ export default class extends Controller {
34
34
  max: { type: Number, default: 3 },
35
35
  duration: { type: Number, default: 4000 },
36
36
  gap: { type: Number, default: 14 },
37
- offset: { type: Number, default: 24 },
38
- theme: { type: String, default: "system" },
39
- richColors: { type: Boolean, default: false },
40
- closeButton: { type: Boolean, default: false },
37
+ // offset is a CSS custom property (--pk-toast-offset) and dir a real
38
+ // dir attribute, both stamped by ToastRegion; theme/richColors are
39
+ // unsupported (region raises) none of them are controller state.
41
40
  hotkey: { type: String, default: "alt+t" },
42
- dir: { type: String, default: "ltr" },
43
41
  }
44
42
 
45
43
  // State the target callbacks touch lives here, NOT in connect(): Stimulus
@@ -50,6 +48,7 @@ export default class extends Controller {
50
48
  this._heights = new Map()
51
49
  this._resizeObservers = new WeakMap()
52
50
  this._expanded = false
51
+ this._paused = false
53
52
  }
54
53
 
55
54
  connect() {
@@ -59,8 +58,11 @@ export default class extends Controller {
59
58
  registerStreamAction()
60
59
  if (!this._listEl) return
61
60
 
62
- this._onPointerEnter = () => this._setExpanded(true)
63
- this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }
61
+ // Pause/resume tracks the POINTER, not the expand transition: an
62
+ // expand:true region is born expanded, and tying the pause dispatch to
63
+ // _setExpanded's state change left auto-dismiss running under the cursor.
64
+ this._onPointerEnter = () => { this._setPaused(true); this._setExpanded(true) }
65
+ this._onPointerLeave = () => { this._setPaused(false); if (!this.expandValue) this._setExpanded(false) }
64
66
  this._onWindowToast = (e) => this._spawn(e.detail || {})
65
67
  this._onWindowDismissAll = () => this._dismissById(null)
66
68
  this._onKey = this._onKey.bind(this)
@@ -82,6 +84,10 @@ export default class extends Controller {
82
84
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
83
85
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
84
86
  document.removeEventListener("keydown", this._onKey)
87
+ // Drop the global API if it still closes over this controller — after
88
+ // region teardown it would touch a stale _listEl. A replacement region
89
+ // re-registers its own on connect.
90
+ if (window.PhlexKit?.toast === this._api) delete window.PhlexKit.toast
85
91
  }
86
92
 
87
93
  toastTargetConnected(el) {
@@ -178,13 +184,21 @@ export default class extends Controller {
178
184
  return tpl.content.firstElementChild.cloneNode(true)
179
185
  }
180
186
 
181
- _setExpanded(value) {
182
- if (this._expanded === value) return
183
- this._expanded = value
187
+ _setPaused(value) {
188
+ if (this._paused === value) return
189
+ this._paused = value
184
190
  // Mirror the pause state onto the DOM so toasts spawned while the stack
185
191
  // is already hovered can arm paused (the pause event predates them).
186
192
  this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
187
- document.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
193
+ // Region-scoped, not document-global: on multi-region pages one region's
194
+ // hover must not pause (or worse, RESUME under the pointer) another's
195
+ // toasts. Toasts listen on their own list element.
196
+ this._listEl.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
197
+ }
198
+
199
+ _setExpanded(value) {
200
+ if (this._expanded === value) return
201
+ this._expanded = value
188
202
  this._reflow()
189
203
  }
190
204
 
@@ -264,7 +278,14 @@ export default class extends Controller {
264
278
  const wantCtrl = parts.includes("ctrl")
265
279
  const wantMeta = parts.includes("meta")
266
280
  const wantShift = parts.includes("shift")
267
- if (e.key.toLowerCase() !== key.toLowerCase()) return
281
+ // Match e.code as well as e.key: with Option held, macOS reports the
282
+ // COMPOSED character (Option+T → "†"), so alt+ hotkeys never match on
283
+ // e.key alone (Sonner matches event.code for the same reason).
284
+ const code = (e.code || "").toLowerCase()
285
+ const keyMatches = e.key.toLowerCase() === key.toLowerCase()
286
+ || code === `key${key.toLowerCase()}`
287
+ || code === `digit${key.toLowerCase()}`
288
+ if (!keyMatches) return
268
289
  if (wantAlt !== e.altKey) return
269
290
  if (wantCtrl !== e.ctrlKey) return
270
291
  if (wantMeta !== e.metaKey) return
@@ -303,6 +324,7 @@ export default class extends Controller {
303
324
  }
304
325
 
305
326
  window.PhlexKit = window.PhlexKit || {}
327
+ this._api = api
306
328
  window.PhlexKit.toast = api
307
329
  }
308
330
 
@@ -1,11 +1,11 @@
1
1
  /* Co-located with toggle_group.rb. Theme tokens from the global stylesheet. */
2
2
  .pk-toggle-group { display: inline-flex; width: fit-content; align-items: center; border-radius: var(--pk-radius); }
3
3
  .pk-toggle-group.vertical { flex-direction: column; align-items: stretch; }
4
- .pk-toggle-group.spaced { gap: .25rem; }
4
+ /* spacing: N stamps --pk-toggle-group-gap inline (N × .25rem). */
5
+ .pk-toggle-group.spaced { gap: var(--pk-toggle-group-gap, .25rem); }
5
6
  /* Joined (default, unspaced): square the inner corners, tighten padding. */
6
- .pk-toggle-group:not(.spaced) .pk-toggle-group-item { padding-inline: .5rem; }
7
- .pk-toggle-group:not(.spaced) .pk-toggle-group-item { border-radius: 0; }
8
- .pk-toggle-group:not(.spaced):not(.vertical) .pk-toggle-group-item:first-of-type { border-top-left-radius: var(--pk-radius); border-bottom-left-radius: var(--pk-radius); }
9
- .pk-toggle-group:not(.spaced):not(.vertical) .pk-toggle-group-item:last-of-type { border-top-right-radius: var(--pk-radius); border-bottom-right-radius: var(--pk-radius); }
10
- .pk-toggle-group.vertical:not(.spaced) .pk-toggle-group-item:first-of-type { border-top-left-radius: var(--pk-radius); border-top-right-radius: var(--pk-radius); }
11
- .pk-toggle-group.vertical:not(.spaced) .pk-toggle-group-item:last-of-type { border-bottom-left-radius: var(--pk-radius); border-bottom-right-radius: var(--pk-radius); }
7
+ .pk-toggle-group:not(.spaced) .pk-toggle-group-item { padding-inline: .5rem; border-radius: 0; }
8
+ .pk-toggle-group:not(.spaced):not(.vertical) .pk-toggle-group-item:first-of-type { border-start-start-radius: var(--pk-radius); border-end-start-radius: var(--pk-radius); }
9
+ .pk-toggle-group:not(.spaced):not(.vertical) .pk-toggle-group-item:last-of-type { border-start-end-radius: var(--pk-radius); border-end-end-radius: var(--pk-radius); }
10
+ .pk-toggle-group.vertical:not(.spaced) .pk-toggle-group-item:first-of-type { border-start-start-radius: var(--pk-radius); border-start-end-radius: var(--pk-radius); }
11
+ .pk-toggle-group.vertical:not(.spaced) .pk-toggle-group-item:last-of-type { border-end-start-radius: var(--pk-radius); border-end-end-radius: var(--pk-radius); }
@@ -21,7 +21,7 @@ module PhlexKit
21
21
 
22
22
  def view_template(&block)
23
23
  div(**mix(group_default_attrs, @attrs)) do
24
- yield(self)
24
+ yield(self) if block
25
25
  render_hidden_inputs
26
26
  end
27
27
  end
@@ -54,12 +54,17 @@ module PhlexKit
54
54
  end
55
55
 
56
56
  def group_default_attrs
57
- { role: (@type == :single) ? "radiogroup" : "group",
57
+ attrs = { role: (@type == :single) ? "radiogroup" : "group",
58
58
  class: [ "pk-toggle-group", (@orientation == :vertical ? "vertical" : nil), (@spacing > 0 ? "spaced" : nil) ].compact.join(" "),
59
59
  data: { controller: "phlex-kit--toggle-group",
60
60
  phlex_kit__toggle_group_type_value: @type.to_s,
61
61
  phlex_kit__toggle_group_name_value: @name.to_s,
62
62
  orientation: @orientation.to_s, spacing: @spacing.to_s } }
63
+ # spacing: N gaps the items by N × .25rem (Tailwind-style scale) via the
64
+ # custom property toggle_group.css reads. Trailing ";" so a caller
65
+ # style: merges into a second valid declaration (Phlex mix gotcha).
66
+ attrs[:style] = "--pk-toggle-group-gap: #{@spacing * 0.25}rem;" if @spacing > 0
67
+ attrs
63
68
  end
64
69
  end
65
70
  end
@@ -17,6 +17,14 @@ export default class extends Controller {
17
17
  }
18
18
  this.rebuildInputs()
19
19
  this.updateRovingTabindex(item)
20
+ // Parity with Toggle: announce the selection as
21
+ // "phlex-kit--toggle-group:change" — single carries the one selected
22
+ // value (radiogroup semantics: no deselect), multiple the pressed array.
23
+ const values = this.itemTargets.filter((el) => this.isPressed(el)).map((el) => el.dataset.value)
24
+ this.dispatch("change", {
25
+ detail: this.typeValue === "single" ? { value: values[0] ?? null } : { value: values },
26
+ bubbles: true
27
+ })
20
28
  }
21
29
 
22
30
  navigate(event) {
@@ -37,11 +45,15 @@ export default class extends Controller {
37
45
  const next = items[nextIndex]
38
46
  this.updateRovingTabindex(next)
39
47
  next.focus()
48
+ // APG radio-group keyboard model: with role="radio", arrows move focus
49
+ // AND check the landed-on item (Home/End included).
50
+ if (next !== event.currentTarget) next.click()
40
51
  }
41
52
 
42
53
  reconcile() {
43
54
  if (this.typeValue === "single") {
44
- const pressed = this.itemTargets.find((el) => this.isPressed(el))
55
+ // A disabled button can't take focus — never hand it the tab stop.
56
+ const pressed = this.itemTargets.find((el) => this.isPressed(el) && !el.disabled)
45
57
  const first = pressed || this.enabledItems()[0]
46
58
  this.itemTargets.forEach((el) => el.setAttribute("tabindex", el === first ? "0" : "-1"))
47
59
  } else {
@@ -1,6 +1,12 @@
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]
7
+ if unsupported.any?
8
+ raise ArgumentError, "ToggleGroupItem does not support #{unsupported.join(", ")} (group items render a bare button)"
9
+ end
4
10
  @item_value = value.to_s
5
11
  @group_context = group_context
6
12
  pressed = group_context[:selected_values].include?(@item_value)
@@ -26,7 +32,9 @@ module PhlexKit
26
32
  if @group_context[:type] == :single
27
33
  a[:role] = "radio"
28
34
  a[:aria] = { checked: @pressed.to_s }
29
- a[:tabindex] = @pressed ? "0" : "-1"
35
+ # A disabled button can't take focus — giving the tab stop to a
36
+ # pressed-but-disabled item made the whole group Tab-unreachable.
37
+ a[:tabindex] = (@pressed && !@disabled) ? "0" : "-1"
30
38
  else
31
39
  a[:aria] = { pressed: @pressed.to_s }
32
40
  a[:tabindex] = "0"
@@ -64,11 +64,11 @@
64
64
  }
65
65
  .pk-tooltip-content.bottom { position-area: block-end; margin: 6px 0 0; }
66
66
  .pk-tooltip-content.left {
67
- position-area: inline-start; margin: 0 6px 0 0;
67
+ position-area: inline-start; margin: 0; margin-inline-end: 6px;
68
68
  position-try-fallbacks: flip-inline;
69
69
  }
70
70
  .pk-tooltip-content.right {
71
- position-area: inline-end; margin: 0 0 0 6px;
71
+ position-area: inline-end; margin: 0; margin-inline-start: 6px;
72
72
  position-try-fallbacks: flip-inline;
73
73
  }
74
74
  /* Hover reveal only on devices that actually hover (a touch tap must not
@@ -79,6 +79,9 @@
79
79
  .pk-tooltip:hover .pk-tooltip-content {
80
80
  opacity: 1;
81
81
  visibility: visible;
82
+ /* WCAG 1.4.13 Hoverable: the revealed bubble must accept the pointer —
83
+ it's a DOM child of .pk-tooltip, so hovering it keeps the reveal. */
84
+ pointer-events: auto;
82
85
  transition-delay: .4s, .4s;
83
86
  }
84
87
  }
@@ -87,9 +90,10 @@
87
90
  .pk-tooltip:has(:focus-visible) .pk-tooltip-content {
88
91
  opacity: 1;
89
92
  visibility: visible;
93
+ pointer-events: auto;
90
94
  transition-delay: 0s, 0s;
91
95
  }
92
96
  /* Escape-dismissed (WCAG 1.4.13): suppressed until pointer/focus leaves.
93
97
  transition: none so the hide is immediate regardless of hover-in delays. */
94
98
  .pk-tooltip[data-pk-dismissed]:hover .pk-tooltip-content,
95
- .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; transition: none; }
99
+ .pk-tooltip[data-pk-dismissed]:focus-within .pk-tooltip-content { opacity: 0; visibility: hidden; pointer-events: none; transition: none; }
@@ -11,7 +11,13 @@ export default class extends Controller {
11
11
  const trigger = this.element.querySelector(".pk-tooltip-trigger")
12
12
  if (content && trigger) {
13
13
  if (!content.id) content.id = `pk-tooltip-${Math.random().toString(36).slice(2, 8)}`
14
- if (!trigger.hasAttribute("aria-describedby")) trigger.setAttribute("aria-describedby", content.id)
14
+ // Describe the element that actually takes focus: with focusable: false
15
+ // the wrapper span never receives focus, so a describedby left only on
16
+ // it is never announced — wire the focusable child too.
17
+ const focusable = trigger.querySelector("button, a[href], input, select, textarea, [tabindex]")
18
+ ;[trigger, focusable].forEach((el) => {
19
+ if (el && !el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", content.id)
20
+ })
15
21
  }
16
22
 
17
23
  this._onKeydown = (e) => {
@@ -8,15 +8,23 @@ module PhlexKit
8
8
  # ruby_ui's level → default size mapping (h1 is big, h4 smaller); 5/6
9
9
  # extend the scale downward — HTML has h1–h6, so all six must map.
10
10
  LEVEL_SIZE = { "1" => "7", "2" => "6", "3" => "5", "4" => "4", "5" => "3", "6" => "2" }.freeze
11
+ # `as:` is dispatched dynamically (send) — whitelist so it can't reach
12
+ # arbitrary (including private) methods. Mirrors Separator/Marker.
13
+ AS_TAGS = %i[h1 h2 h3 h4 h5 h6 p span div].freeze
11
14
 
12
- def initialize(level: nil, as: nil, size: nil, **attrs)
15
+ # level: defaults to 1 so `Heading.new` is identical to `Heading.new(level: 1)`
16
+ # (a nil default used to render h1 with the level-6 size — self-disagreeing).
17
+ def initialize(level: 1, as: nil, size: nil, **attrs)
13
18
  # Fail loud at construction: level 7 previously exploded at render time
14
19
  # with NoMethodError (no h7 element), and 5/6 fell through the size map.
15
- unless level.nil? || LEVEL_SIZE.key?(level.to_s)
20
+ unless LEVEL_SIZE.key?(level.to_s)
16
21
  raise ArgumentError, "Invalid heading level: #{level.inspect} — valid: 1–6"
17
22
  end
18
23
  @level = level
19
- @as = as
24
+ @as = as&.to_sym
25
+ if @as && !AS_TAGS.include?(@as)
26
+ raise ArgumentError, "unknown Heading as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
27
+ end
20
28
  @size = size
21
29
  @attrs = attrs
22
30
  end
@@ -28,11 +36,11 @@ module PhlexKit
28
36
  private
29
37
 
30
38
  def element
31
- (@as || (@level ? "h#{@level}" : "h1")).to_sym
39
+ (@as || "h#{@level}").to_sym
32
40
  end
33
41
 
34
42
  def size_token
35
- token = (@size || LEVEL_SIZE[@level.to_s] || "6").to_s
43
+ token = (@size || LEVEL_SIZE.fetch(@level.to_s)).to_s
36
44
  raise ArgumentError, "Invalid heading size: #{token.inspect}" unless SIZES.include?(token)
37
45
  token
38
46
  end
@@ -0,0 +1,22 @@
1
+ module PhlexKit
2
+ # Prose list, ported from shadcn/ui's typography "list" style (ruby_ui's
3
+ # TypographyList). Renders a ul (or ol via as:) with the .pk-list styling
4
+ # that previously shipped as an orphaned utility class. Children are plain
5
+ # <li>s.
6
+ class List < BaseComponent
7
+ # `as:` is dispatched dynamically (send) — whitelist like Separator.
8
+ AS_TAGS = %i[ul ol].freeze
9
+
10
+ def initialize(as: :ul, **attrs)
11
+ @as = as.to_sym
12
+ unless AS_TAGS.include?(@as)
13
+ raise ArgumentError, "unknown List as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
14
+ end
15
+ @attrs = attrs
16
+ end
17
+
18
+ def view_template(&)
19
+ send(@as, **mix({ class: "pk-list" }, @attrs), &)
20
+ end
21
+ end
22
+ end
@@ -27,9 +27,15 @@ module PhlexKit
27
27
  bold: "pk-text-bold",
28
28
  muted: "pk-text-muted"
29
29
  }.freeze
30
+ # `as:` is dispatched dynamically (send) — whitelist so it can't reach
31
+ # arbitrary (including private) methods. Mirrors Separator/Marker.
32
+ AS_TAGS = %i[p span div label small strong em].freeze
30
33
 
31
34
  def initialize(as: :p, size: "3", weight: :regular, **attrs)
32
- @as = as
35
+ @as = as.to_sym
36
+ unless AS_TAGS.include?(@as)
37
+ raise ArgumentError, "unknown Text as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
38
+ end
33
39
  @size = size.to_s
34
40
  @weight = weight.to_sym
35
41
  @attrs = attrs
@@ -30,5 +30,18 @@ module PhlexKit
30
30
  "valid: #{map.keys.map(&:inspect).join(", ")}"
31
31
  end
32
32
  end
33
+
34
+ # True when the caller supplied an accessible name in @attrs — via the
35
+ # aria: hash or flat aria_label/aria-label(ledby) keys. Components with a
36
+ # generated default label must skip it then: `mix` merges duplicate string
37
+ # attrs ("pagination Résultats") instead of overriding.
38
+ def aria_labelled?
39
+ aria = @attrs[:aria] || @attrs["aria"]
40
+ if aria.is_a?(Hash)
41
+ return true if %i[label labelledby].any? { |k| aria[k] || aria[k.to_s] }
42
+ end
43
+ [ :aria_label, "aria_label", "aria-label",
44
+ :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
45
+ end
33
46
  end
34
47
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.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.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy
@@ -373,6 +373,7 @@ files:
373
373
  - app/components/phlex_kit/hover_card/hover_card_content.rb
374
374
  - app/components/phlex_kit/hover_card/hover_card_controller.js
375
375
  - app/components/phlex_kit/hover_card/hover_card_trigger.rb
376
+ - app/components/phlex_kit/icon/icon.css
376
377
  - app/components/phlex_kit/icon/icon.rb
377
378
  - app/components/phlex_kit/input/input.css
378
379
  - app/components/phlex_kit/input/input.rb
@@ -587,6 +588,7 @@ files:
587
588
  - app/components/phlex_kit/typography/heading.rb
588
589
  - app/components/phlex_kit/typography/inline_code.rb
589
590
  - app/components/phlex_kit/typography/inline_link.rb
591
+ - app/components/phlex_kit/typography/list.rb
590
592
  - app/components/phlex_kit/typography/text.rb
591
593
  - app/components/phlex_kit/typography/typography.css
592
594
  - app/javascript/phlex_kit/controllers/index.js