phlex_kit 0.9.1 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/phlex_kit/_tokens.css +8 -0
  3. data/app/components/phlex_kit/alert/alert.rb +1 -1
  4. data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +12 -0
  5. data/app/components/phlex_kit/attachment/attachment.css +6 -3
  6. data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
  7. data/app/components/phlex_kit/avatar/avatar.css +2 -2
  8. data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
  9. data/app/components/phlex_kit/bubble/bubble.css +1 -1
  10. data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
  11. data/app/components/phlex_kit/button_group/button_group.css +16 -16
  12. data/app/components/phlex_kit/calendar/calendar.css +10 -2
  13. data/app/components/phlex_kit/carousel/carousel.css +4 -4
  14. data/app/components/phlex_kit/clipboard/clipboard_controller.js +18 -2
  15. data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
  16. data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
  17. data/app/components/phlex_kit/combobox/combobox.css +3 -3
  18. data/app/components/phlex_kit/combobox/combobox.rb +3 -3
  19. data/app/components/phlex_kit/combobox/combobox_controller.js +17 -9
  20. data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
  21. data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +3 -1
  22. data/app/components/phlex_kit/command/command.css +2 -2
  23. data/app/components/phlex_kit/command/command_controller.js +57 -34
  24. data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
  25. data/app/components/phlex_kit/command/command_group.rb +5 -2
  26. data/app/components/phlex_kit/dialog/dialog.css +2 -2
  27. data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
  28. data/app/components/phlex_kit/dialog/dialog_controller.js +9 -0
  29. data/app/components/phlex_kit/drawer/drawer.css +4 -4
  30. data/app/components/phlex_kit/empty/empty.css +1 -1
  31. data/app/components/phlex_kit/field/field.css +2 -1
  32. data/app/components/phlex_kit/form_field/form_field_controller.js +7 -0
  33. data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
  34. data/app/components/phlex_kit/marker/marker.rb +6 -0
  35. data/app/components/phlex_kit/masked_input/masked_input_controller.js +17 -3
  36. data/app/components/phlex_kit/menubar/menubar.rb +3 -1
  37. data/app/components/phlex_kit/menubar/menubar_controller.js +22 -1
  38. data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
  39. data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
  40. data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +9 -5
  41. data/app/components/phlex_kit/native_select/native_select.css +4 -2
  42. data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
  43. data/app/components/phlex_kit/pagination/pagination_next.rb +9 -2
  44. data/app/components/phlex_kit/pagination/pagination_previous.rb +9 -2
  45. data/app/components/phlex_kit/popover/popover_controller.js +9 -1
  46. data/app/components/phlex_kit/resizable/resizable_controller.js +49 -5
  47. data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
  48. data/app/components/phlex_kit/scroll_area/scroll_area.rb +20 -4
  49. data/app/components/phlex_kit/select/select.css +0 -1
  50. data/app/components/phlex_kit/select/select_content.rb +9 -3
  51. data/app/components/phlex_kit/select/select_controller.js +32 -2
  52. data/app/components/phlex_kit/select/select_item.rb +5 -1
  53. data/app/components/phlex_kit/separator/separator.rb +7 -1
  54. data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
  55. data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
  56. data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
  57. data/app/components/phlex_kit/tabs/tabs.css +2 -2
  58. data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
  59. data/app/components/phlex_kit/tabs/tabs_trigger.rb +4 -1
  60. data/app/components/phlex_kit/toast/toast.css +1 -1
  61. data/app/components/phlex_kit/toast/toast_controller.js +6 -1
  62. data/app/components/phlex_kit/toast/toaster_controller.js +19 -5
  63. data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
  64. data/app/components/phlex_kit/toggle_group/toggle_group.rb +6 -1
  65. data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +8 -0
  66. data/app/components/phlex_kit/tooltip/tooltip.css +2 -2
  67. data/app/components/phlex_kit/typography/heading.rb +6 -4
  68. data/lib/phlex_kit/version.rb +1 -1
  69. metadata +1 -1
@@ -33,7 +33,15 @@ export default class extends Controller {
33
33
  this.contentTarget.togglePopover()
34
34
  }
35
35
 
36
+ // One stable handler added/removed symmetrically — an anonymous listener
37
+ // here would pile up a duplicate on every target reconnect.
38
+ syncState = (e) => { e.target.dataset.state = e.newState === "open" ? "open" : "closed" }
39
+
36
40
  contentTargetConnected(el) {
37
- el.addEventListener("toggle", (e) => { el.dataset.state = e.newState === "open" ? "open" : "closed" })
41
+ el.addEventListener("toggle", this.syncState)
42
+ }
43
+
44
+ contentTargetDisconnected(el) {
45
+ el.removeEventListener("toggle", this.syncState)
38
46
  }
39
47
  }
@@ -47,15 +47,21 @@ export default class extends Controller {
47
47
  // constant: normalizing to 2 rescaled the pair against untouched
48
48
  // siblings, so in a 25/25/50 group the 50 panel ballooned on first drag.
49
49
  pairGrow: this.growOf(prev) + this.growOf(next) || 2,
50
+ // Snapshot the group's total grow: min/max-size percentages are shares
51
+ // of the WHOLE group, and untouched siblings keep their grow mid-drag.
52
+ groupGrow: this.groupGrow(),
50
53
  }
51
54
  try { handle.setPointerCapture(e.pointerId) } catch {}
52
55
 
53
56
  const onMove = (ev) => {
57
+ // LTR assumption: clientX grows toward the trailing panel; RTL drag
58
+ // inversion is a documented limitation.
54
59
  const delta = (horizontal ? ev.clientX : ev.clientY) - drag.startPos
55
60
  const total = drag.prevSize + drag.nextSize
56
61
  const prevSize = Math.min(Math.max(drag.prevSize + delta, 0), total)
57
- prev.style.flexGrow = (prevSize / total) * drag.pairGrow
58
- next.style.flexGrow = ((total - prevSize) / total) * drag.pairGrow
62
+ const prevGrow = this.clampPrevGrow(prev, next, (prevSize / total) * drag.pairGrow, drag.pairGrow, drag.groupGrow)
63
+ prev.style.flexGrow = prevGrow
64
+ next.style.flexGrow = drag.pairGrow - prevGrow
59
65
  this.syncValuenow(handle)
60
66
  }
61
67
  const onUp = () => {
@@ -71,7 +77,8 @@ export default class extends Controller {
71
77
  }
72
78
 
73
79
  // Arrow keys resize by 5% of the pair per press (matching the drag axis);
74
- // Home/End collapse the leading panel to its min/max.
80
+ // Home/End collapse the leading panel to its min/max (clamped to any
81
+ // per-panel min-size/max-size bounds, same as drags).
75
82
  keydown(e) {
76
83
  const handle = e.currentTarget
77
84
  const prev = handle.previousElementSibling
@@ -96,8 +103,9 @@ export default class extends Controller {
96
103
  }
97
104
  e.preventDefault()
98
105
 
99
- prev.style.flexGrow = share * pairGrow
100
- next.style.flexGrow = (1 - share) * pairGrow
106
+ const prevGrow = this.clampPrevGrow(prev, next, share * pairGrow, pairGrow, this.groupGrow())
107
+ prev.style.flexGrow = prevGrow
108
+ next.style.flexGrow = pairGrow - prevGrow
101
109
  this.syncValuenow(handle)
102
110
  }
103
111
 
@@ -106,6 +114,42 @@ export default class extends Controller {
106
114
  return Number.isFinite(g) ? g : 1
107
115
  }
108
116
 
117
+ // Clamp the leading panel's proposed flex-grow to both panels' optional
118
+ // min-size/max-size percentages. Bounds are shares of the whole group
119
+ // (grow / groupGrow), and the pair's combined grow is fixed, so a bound on
120
+ // one panel implies the complementary bound on its neighbour. Without any
121
+ // bounds this collapses to the historical [0, pairGrow] clamp.
122
+ clampPrevGrow(prev, next, prevGrow, pairGrow, groupGrow) {
123
+ const pb = this.boundsOf(prev)
124
+ const nb = this.boundsOf(next)
125
+ let lo = 0
126
+ let hi = pairGrow
127
+ if (pb.min !== null) lo = Math.max(lo, pb.min * groupGrow)
128
+ if (nb.max !== null) lo = Math.max(lo, pairGrow - nb.max * groupGrow)
129
+ if (pb.max !== null) hi = Math.min(hi, pb.max * groupGrow)
130
+ if (nb.min !== null) hi = Math.min(hi, pairGrow - nb.min * groupGrow)
131
+ if (lo > hi) return Math.min(Math.max(prevGrow, 0), pairGrow) // unsatisfiable bounds: ignore them
132
+ return Math.min(Math.max(prevGrow, lo), hi)
133
+ }
134
+
135
+ // A panel's declared size bounds as fractions of the group, or nulls.
136
+ boundsOf(el) {
137
+ const min = parseFloat(el.getAttribute("data-phlex-kit--resizable-min-size"))
138
+ const max = parseFloat(el.getAttribute("data-phlex-kit--resizable-max-size"))
139
+ return {
140
+ min: Number.isFinite(min) ? min / 100 : null,
141
+ max: Number.isFinite(max) ? max / 100 : null,
142
+ }
143
+ }
144
+
145
+ // Combined flex-grow of this group's own panels (direct children only —
146
+ // panels of a nested group belong to that group's own controller).
147
+ groupGrow() {
148
+ return [...this.element.children]
149
+ .filter((el) => el.matches('[data-phlex-kit--resizable-target~="panel"]'))
150
+ .reduce((sum, el) => sum + this.growOf(el), 0) || 2
151
+ }
152
+
109
153
  syncValuenow(handle) {
110
154
  const prev = handle.previousElementSibling
111
155
  const next = handle.nextElementSibling
@@ -1,11 +1,16 @@
1
1
  module PhlexKit
2
- # One pane of a ResizablePanelGroup. `default_size:` is a percentage.
2
+ # One pane of a ResizablePanelGroup. `default_size:`, `min_size:` and
3
+ # `max_size:` are percentages of the group (shadcn's defaultSize/minSize/
4
+ # maxSize); the bounds ride along as data attributes the resizable
5
+ # controller clamps drags and keyboard resizes against.
3
6
  # See resizable_panel_group.rb.
4
7
  class ResizablePanel < BaseComponent
5
- def initialize(default_size: nil, **attrs)
6
- # Numeric coercion: the value is interpolated into a style attribute, and
8
+ def initialize(default_size: nil, min_size: nil, max_size: nil, **attrs)
9
+ # Numeric coercion: the values are interpolated into attributes, and
7
10
  # hosts plausibly round-trip saved panel sizes from client state.
8
11
  @default_size = default_size.nil? ? nil : Float(default_size)
12
+ @min_size = min_size.nil? ? nil : Float(min_size)
13
+ @max_size = max_size.nil? ? nil : Float(max_size)
9
14
  @attrs = attrs
10
15
  end
11
16
 
@@ -16,7 +21,11 @@ module PhlexKit
16
21
  div(**mix({
17
22
  class: "pk-resizable-panel",
18
23
  style: style,
19
- data: { phlex_kit__resizable_target: "panel" }
24
+ data: {
25
+ phlex_kit__resizable_target: "panel",
26
+ phlex_kit__resizable_min_size: @min_size && Kernel.format("%g", @min_size),
27
+ phlex_kit__resizable_max_size: @max_size && Kernel.format("%g", @max_size)
28
+ }.compact
20
29
  }.compact, @attrs), &)
21
30
  end
22
31
  end
@@ -2,16 +2,32 @@ module PhlexKit
2
2
  # Styled scroll container, ported from shadcn/ui's ScrollArea. Radix's custom
3
3
  # scrollbars are replaced with native thin scrollbars themed via CSS — no JS.
4
4
  # Constrain it with a height/width from the caller. tabindex=0 makes the
5
- # scrollable region keyboard-focusable (WCAG); role=region gives that focus
6
- # stop a role — pass `aria: { label: ... }` to name it. `.pk-scroll-area`
7
- # (scroll_area.css).
5
+ # scrollable region keyboard-focusable (WCAG scrollable-region-focusable).
6
+ # Pass `aria: { label: ... }` (or aria-labelledby) to name it — only then
7
+ # does it render role=region; a nameless region is noise for AT, so the
8
+ # role is omitted when unlabelled. `.pk-scroll-area` (scroll_area.css).
8
9
  class ScrollArea < BaseComponent
9
10
  def initialize(**attrs)
10
11
  @attrs = attrs
11
12
  end
12
13
 
13
14
  def view_template(&)
14
- div(**mix({ class: "pk-scroll-area", tabindex: "0", role: "region" }, @attrs), &)
15
+ base = { class: "pk-scroll-area", tabindex: "0" }
16
+ base[:role] = "region" if labelled?
17
+ div(**mix(base, @attrs), &)
18
+ end
19
+
20
+ private
21
+
22
+ # True when the caller supplies an accessible name, via the aria: hash
23
+ # (aria: { label:/labelledby: }) or flat aria_label/aria-labelledby attrs.
24
+ def labelled?
25
+ aria = @attrs[:aria] || @attrs["aria"]
26
+ if aria.is_a?(Hash)
27
+ return true if %w[label labelledby].any? { |k| aria[k.to_sym] || aria[k] }
28
+ end
29
+ [ :aria_label, "aria_label", "aria-label",
30
+ :aria_labelledby, "aria_labelledby", "aria-labelledby" ].any? { |k| @attrs[k] }
15
31
  end
16
32
  end
17
33
  end
@@ -22,7 +22,6 @@
22
22
  white-space: nowrap;
23
23
  border-radius: var(--pk-radius);
24
24
  border: 1px solid var(--pk-input);
25
- background: transparent;
26
25
  color: var(--pk-text);
27
26
  padding: .25rem .5rem .25rem .625rem;
28
27
  gap: .375rem;
@@ -5,8 +5,11 @@ module PhlexKit
5
5
  # inner `.pk-select-viewport` is the bordered, scrollable box.
6
6
  # Holds SelectGroup / SelectLabel / SelectItem children. See select.rb.
7
7
  class SelectContent < BaseComponent
8
- def initialize(**attrs)
9
- @id = "pk-select-content-#{SecureRandom.hex(4)}"
8
+ # id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
9
+ # caller id with the generated one into an invalid two-token id, breaking
10
+ # aria-controls and the generated item ids.
11
+ def initialize(id: nil, **attrs)
12
+ @id = id || "pk-select-content-#{SecureRandom.hex(4)}"
10
13
  @attrs = attrs
11
14
  end
12
15
 
@@ -17,7 +20,10 @@ module PhlexKit
17
20
  tabindex: "-1",
18
21
  class: "pk-select-content",
19
22
  popover: "manual",
20
- data: { phlex_kit__select_target: "content" }
23
+ data: {
24
+ phlex_kit__select_target: "content",
25
+ action: "keydown->phlex-kit--select#typeahead"
26
+ }
21
27
  }, @attrs)) do
22
28
  div(class: "pk-select-viewport", &block)
23
29
  end
@@ -21,6 +21,9 @@ export default class extends Controller {
21
21
  // document-scoped outlet: outlets match `.pk-select-item` page-wide and
22
22
  // clobbered every other Select's aria-selected.
23
23
  const item = event.currentTarget;
24
+ // A malformed (hand-written) item without data-value must never submit
25
+ // the literal string "undefined". SelectItem itself fails loud on nil.
26
+ if (item.dataset.value === undefined) return;
24
27
  this.itemTargets.forEach((el) => {
25
28
  el.setAttribute("aria-selected", el === item ? "true" : "false");
26
29
  });
@@ -36,7 +39,7 @@ export default class extends Controller {
36
39
  }
37
40
 
38
41
  onClick() {
39
- this.toogleContent();
42
+ this.toggleContent();
40
43
 
41
44
  if (this.openValue) {
42
45
  this.setFocusAndCurrent();
@@ -127,10 +130,37 @@ export default class extends Controller {
127
130
  // Open/close derive from the live :popover-open state, never a stored
128
131
  // flag — a stale flag is how a close on an already-closed panel becomes
129
132
  // an open (bit the popover's keyboard toggle).
130
- toogleContent() {
133
+ toggleContent() {
131
134
  this.contentTarget.matches(":popover-open") ? this.#hide() : this.#show();
132
135
  }
133
136
 
137
+ // Listbox typeahead: printable keys accumulate into a short-lived buffer
138
+ // and move the highlight to the first option whose text starts with it.
139
+ typeahead(event) {
140
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
141
+ if (event.key.length !== 1 || event.key === " ") return; // Space selects
142
+ clearTimeout(this.typeaheadTimer);
143
+ this.typeaheadBuffer = (this.typeaheadBuffer || "") + event.key.toLowerCase();
144
+ this.typeaheadTimer = setTimeout(() => { this.typeaheadBuffer = ""; }, 500);
145
+
146
+ let index = this.itemTargets.findIndex((item) =>
147
+ item.innerText.trim().toLowerCase().startsWith(this.typeaheadBuffer));
148
+ if (index < 0) {
149
+ // Buffer stopped matching — restart it from this keystroke (APG
150
+ // typeahead: a failed prefix shouldn't dead-end until the timer clears).
151
+ this.typeaheadBuffer = event.key.toLowerCase();
152
+ index = this.itemTargets.findIndex((item) =>
153
+ item.innerText.trim().toLowerCase().startsWith(this.typeaheadBuffer));
154
+ }
155
+ if (index < 0) return;
156
+ this.resetCurrent();
157
+ this.setAriaCurrentAndActiveDescendant(index);
158
+ }
159
+
160
+ disconnect() {
161
+ clearTimeout(this.typeaheadTimer);
162
+ }
163
+
134
164
  #show() {
135
165
  this.openValue = true;
136
166
  this.contentTarget.showPopover();
@@ -7,7 +7,10 @@ module PhlexKit
7
7
  # repeated attribute rather than override it.
8
8
  # See select.rb.
9
9
  class SelectItem < BaseComponent
10
- def initialize(value: nil, selected: false, **attrs)
10
+ def initialize(value:, selected: false, **attrs)
11
+ # nil would render no data-value at all and the controller would submit
12
+ # the literal string "undefined" — fail loud instead.
13
+ raise ArgumentError, "SelectItem requires a value:" if value.nil?
11
14
  @value = value
12
15
  @selected = selected
13
16
  @attrs = attrs
@@ -25,6 +28,7 @@ module PhlexKit
25
28
  data: {
26
29
  value: @value,
27
30
  action: "click->phlex-kit--select#selectItem keydown.enter->phlex-kit--select#selectItem " \
31
+ "keydown.space->phlex-kit--select#selectItem " \
28
32
  "keydown.down->phlex-kit--select#handleKeyDown keydown.up->phlex-kit--select#handleKeyUp " \
29
33
  "keydown.home->phlex-kit--select#handleHome keydown.end->phlex-kit--select#handleEnd " \
30
34
  "keydown.esc->phlex-kit--select#handleEsc",
@@ -6,9 +6,15 @@ module PhlexKit
6
6
  # the kit's fail-loud selectors.
7
7
  class Separator < BaseComponent
8
8
  ORIENTATIONS = %i[horizontal vertical].freeze
9
+ # `as:` is dispatched dynamically (send) — whitelist the documented
10
+ # elements so it can't reach arbitrary (including private) methods.
11
+ AS_TAGS = %i[div hr].freeze
9
12
 
10
13
  def initialize(as: :div, orientation: :horizontal, decorative: true, **attrs)
11
- @as = as
14
+ @as = as.to_sym
15
+ unless AS_TAGS.include?(@as)
16
+ raise ArgumentError, "unknown Separator as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
17
+ end
12
18
  @orientation = orientation.to_sym
13
19
  unless ORIENTATIONS.include?(@orientation)
14
20
  raise ArgumentError, "Invalid orientation: #{orientation.inspect}"
@@ -4,7 +4,7 @@ module PhlexKit
4
4
  class SheetClose < BaseComponent
5
5
  def initialize(**attrs) = (@attrs = attrs)
6
6
  def view_template(&)
7
- span(**mix({ style: "display: contents", data: { action: "click->phlex-kit--sheet-content#close" } }, @attrs), &)
7
+ span(**mix({ style: "display: contents;", data: { action: "click->phlex-kit--sheet-content#close" } }, @attrs), &)
8
8
  end
9
9
  end
10
10
  end
@@ -66,7 +66,7 @@
66
66
  width: 100%;
67
67
  padding: .5rem .65rem;
68
68
  border: 0;
69
- border-radius: 8px;
69
+ border-radius: calc(var(--pk-radius) - 2px);
70
70
  background: transparent;
71
71
  color: var(--pk-sidebar-text, var(--pk-text));
72
72
  font: inherit;
@@ -25,11 +25,21 @@ export default class extends Controller {
25
25
  static COOKIE = "pk_sidebar_state"
26
26
 
27
27
  connect() {
28
+ // Crossing to desktop while the mobile drawer is open would strand the
29
+ // page inert: the scrim/drawer CSS stops applying at >=768px but the
30
+ // inert marks (and data-open) would survive. Close on breakpoint change.
31
+ this.mql = window.matchMedia(this.constructor.MOBILE)
32
+ this.onBreakpoint = () => {
33
+ if (!this.mql.matches) this.closeMobile()
34
+ this.#syncExpanded()
35
+ }
36
+ this.mql.addEventListener("change", this.onBreakpoint)
28
37
  this.#wireAriaControls()
29
38
  this.#syncExpanded()
30
39
  }
31
40
 
32
41
  disconnect() {
42
+ this.mql.removeEventListener("change", this.onBreakpoint)
33
43
  this.#restoreInert()
34
44
  }
35
45
 
@@ -44,7 +44,7 @@
44
44
  border-color: transparent;
45
45
  box-shadow: none;
46
46
  }
47
- .pk-tabs-trigger::after {
47
+ .pk-tabs-list.line .pk-tabs-trigger::after {
48
48
  content: "";
49
49
  position: absolute;
50
50
  inset-inline: 0;
@@ -54,7 +54,7 @@
54
54
  opacity: 0;
55
55
  transition: opacity .15s ease;
56
56
  }
57
- .pk-tabs.vertical .pk-tabs-trigger::after {
57
+ .pk-tabs.vertical .pk-tabs-list.line .pk-tabs-trigger::after {
58
58
  inset-inline: auto;
59
59
  inset-block: 0;
60
60
  inset-inline-end: -.25rem;
@@ -13,7 +13,10 @@ export default class extends Controller {
13
13
  this.listTarget.setAttribute("aria-orientation", "vertical")
14
14
  }
15
15
  if (!this.hasActiveValue && this.triggerTargets.length > 0) {
16
- this.activeValue = this.triggerTargets[0].dataset.value
16
+ // Honor a server-marked TabsTrigger(active: true) before falling back
17
+ // to the first trigger — hydration must not contradict the markup.
18
+ const marked = this.triggerTargets.find((el) => el.dataset.state === "active")
19
+ this.activeValue = (marked || this.triggerTargets[0]).dataset.value
17
20
  } else {
18
21
  this.sync()
19
22
  }
@@ -17,7 +17,10 @@ module PhlexKit
17
17
  aria_selected: @active ? "true" : "false",
18
18
  aria_controls: "pk-tabs-panel-#{@value}",
19
19
  tabindex: @active ? "0" : "-1",
20
- data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value }
20
+ # data-state is the only hook tabs.css styles the active trigger by —
21
+ # it must be server-rendered or the active tab looks inactive pre-JS.
22
+ data: { phlex_kit__tabs_target: "trigger", action: "click->phlex-kit--tabs#show", value: @value,
23
+ state: @active ? "active" : "inactive" }
21
24
  }
22
25
  if @as == :a
23
26
  a(**mix(base, @attrs), &)
@@ -40,7 +40,7 @@
40
40
  width: 356px;
41
41
  max-width: 100%;
42
42
  border: 1px solid var(--pk-border);
43
- border-radius: 1rem;
43
+ border-radius: calc(var(--pk-radius) + 6px); /* 1rem at the default 0.625rem token */
44
44
  background: var(--pk-surface);
45
45
  color: var(--pk-text);
46
46
  padding: 1rem;
@@ -17,6 +17,7 @@ export default class extends Controller {
17
17
 
18
18
  connect() {
19
19
  this._timer = null
20
+ this._unmountTimer = null
20
21
  this._startedAt = 0
21
22
  this._remaining = this.durationValue
22
23
  this._paused = false
@@ -66,6 +67,10 @@ export default class extends Controller {
66
67
 
67
68
  disconnect() {
68
69
  this._clearTimer()
70
+ // Don't let a pending 200ms unmount fire after teardown (e.g. into a
71
+ // Turbo-cached copy of the page).
72
+ if (this._unmountTimer) clearTimeout(this._unmountTimer)
73
+ this._unmountTimer = null
69
74
  this.element.removeEventListener("pointerdown", this._onPointerDown)
70
75
  this.element.removeEventListener("pointerenter", this._onPointerEnter)
71
76
  this.element.removeEventListener("pointerleave", this._onPointerLeave)
@@ -89,7 +94,7 @@ export default class extends Controller {
89
94
  this.element.dataset.state = "closing"
90
95
  this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "phlex-kit:toast:auto-close" : "phlex-kit:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
91
96
  this._invokeCallback(reason === "auto" ? this.onAutoCloseValue : this.onDismissValue)
92
- setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
97
+ this._unmountTimer = setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
93
98
  }
94
99
 
95
100
  // on_dismiss:/on_auto_close: name a global function (Sonner-style callback
@@ -50,6 +50,7 @@ export default class extends Controller {
50
50
  this._heights = new Map()
51
51
  this._resizeObservers = new WeakMap()
52
52
  this._expanded = false
53
+ this._paused = false
53
54
  }
54
55
 
55
56
  connect() {
@@ -59,8 +60,11 @@ export default class extends Controller {
59
60
  registerStreamAction()
60
61
  if (!this._listEl) return
61
62
 
62
- this._onPointerEnter = () => this._setExpanded(true)
63
- this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }
63
+ // Pause/resume tracks the POINTER, not the expand transition: an
64
+ // expand:true region is born expanded, and tying the pause dispatch to
65
+ // _setExpanded's state change left auto-dismiss running under the cursor.
66
+ this._onPointerEnter = () => { this._setPaused(true); this._setExpanded(true) }
67
+ this._onPointerLeave = () => { this._setPaused(false); if (!this.expandValue) this._setExpanded(false) }
64
68
  this._onWindowToast = (e) => this._spawn(e.detail || {})
65
69
  this._onWindowDismissAll = () => this._dismissById(null)
66
70
  this._onKey = this._onKey.bind(this)
@@ -82,6 +86,10 @@ export default class extends Controller {
82
86
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
83
87
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
84
88
  document.removeEventListener("keydown", this._onKey)
89
+ // Drop the global API if it still closes over this controller — after
90
+ // region teardown it would touch a stale _listEl. A replacement region
91
+ // re-registers its own on connect.
92
+ if (window.PhlexKit?.toast === this._api) delete window.PhlexKit.toast
85
93
  }
86
94
 
87
95
  toastTargetConnected(el) {
@@ -178,13 +186,18 @@ export default class extends Controller {
178
186
  return tpl.content.firstElementChild.cloneNode(true)
179
187
  }
180
188
 
181
- _setExpanded(value) {
182
- if (this._expanded === value) return
183
- this._expanded = value
189
+ _setPaused(value) {
190
+ if (this._paused === value) return
191
+ this._paused = value
184
192
  // Mirror the pause state onto the DOM so toasts spawned while the stack
185
193
  // is already hovered can arm paused (the pause event predates them).
186
194
  this._listEl?.toggleAttribute("data-pk-toasts-paused", value)
187
195
  document.dispatchEvent(new CustomEvent(value ? "phlex-kit:toast:pause" : "phlex-kit:toast:resume"))
196
+ }
197
+
198
+ _setExpanded(value) {
199
+ if (this._expanded === value) return
200
+ this._expanded = value
188
201
  this._reflow()
189
202
  }
190
203
 
@@ -303,6 +316,7 @@ export default class extends Controller {
303
316
  }
304
317
 
305
318
  window.PhlexKit = window.PhlexKit || {}
319
+ this._api = api
306
320
  window.PhlexKit.toast = api
307
321
  }
308
322
 
@@ -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); }
@@ -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) {
@@ -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
@@ -9,10 +9,12 @@ module PhlexKit
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
11
 
12
- def initialize(level: nil, as: nil, size: nil, **attrs)
12
+ # level: defaults to 1 so `Heading.new` is identical to `Heading.new(level: 1)`
13
+ # (a nil default used to render h1 with the level-6 size — self-disagreeing).
14
+ def initialize(level: 1, as: nil, size: nil, **attrs)
13
15
  # Fail loud at construction: level 7 previously exploded at render time
14
16
  # with NoMethodError (no h7 element), and 5/6 fell through the size map.
15
- unless level.nil? || LEVEL_SIZE.key?(level.to_s)
17
+ unless LEVEL_SIZE.key?(level.to_s)
16
18
  raise ArgumentError, "Invalid heading level: #{level.inspect} — valid: 1–6"
17
19
  end
18
20
  @level = level
@@ -28,11 +30,11 @@ module PhlexKit
28
30
  private
29
31
 
30
32
  def element
31
- (@as || (@level ? "h#{@level}" : "h1")).to_sym
33
+ (@as || "h#{@level}").to_sym
32
34
  end
33
35
 
34
36
  def size_token
35
- token = (@size || LEVEL_SIZE[@level.to_s] || "6").to_s
37
+ token = (@size || LEVEL_SIZE.fetch(@level.to_s)).to_s
36
38
  raise ArgumentError, "Invalid heading size: #{token.inspect}" unless SIZES.include?(token)
37
39
  token
38
40
  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.10.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.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy