phlex_kit 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60c4cbd0615b995e62c9dee3f19ae2c2f48ff53c5ec7f8294bf757edb7f68a25
4
- data.tar.gz: b95a0dcc6976d561bb1e8d005eac32607e771386d236735b3e45e4316b0ef4c7
3
+ metadata.gz: 7e817130c54d3e6a8c547328c09b8e632e13adda0cafa7ed0f415359fd1c0ed9
4
+ data.tar.gz: bcb04540a46d3442ac21b8d54d10b1f903bbcf33af2ec65344e7ad6879ab8462
5
5
  SHA512:
6
- metadata.gz: e76f08c320a673fbcda5e136148933c12e26cbc9a9b97f8f253382bf9b9f11f26255de504cb27b67de2b1f0de8b6d40bb1f8e257b1da5c809259c5575c0489da
7
- data.tar.gz: 130fb551c26417da890ec8da4dc1cc06187ae4dc6916129e3c8a862e3da14c3317e94d6c15ce0d8afa23e353aeacd7449e7d5aaa38ca43049a3a105932d68c30
6
+ metadata.gz: ad56db4fec9925c964ed13084658ddf29942d79d17d4cd141bdb3efa0bef5d3e0be0c6c9ff8f53b28da163dfb0f080c1a79c8330b806f01823e0e092b479a5f5
7
+ data.tar.gz: 3e90c49dc0cb097b2aee66a28d7537c7c834df03015338832e6f00c9cbe40c93374d7a1d034a3c9765b848dc12407afc44681d2b6ef4aa62176295fecb1f3ad2
@@ -16,7 +16,11 @@ module PhlexKit
16
16
  # the year list). Pass `input_id:` a CSS selector (e.g. "#due-date") to
17
17
  # push the picked date (or "start – end" range) into a
18
18
  # phlex-kit--calendar-input outlet — that's how PhlexKit::DatePicker binds
19
- # an Input. Sizing rides --pk-cell-size/--pk-cell-radius (calendar.css).
19
+ # an Input. `locale:` (a BCP 47 tag) drives every Intl call the controller
20
+ # makes — caption, weekday headers, month dropdown, day accessible names and
21
+ # the EEEE/MMMM format tokens; unset follows the browser. The server-rendered
22
+ # month/weekday words stay English as the pre-JS baseline. Note the `do`/PPPP
23
+ # ordinal suffixes (st/nd/rd/th) are an English rule and stay English. Sizing rides --pk-cell-size/--pk-cell-radius (calendar.css).
20
24
  class Calendar < BaseComponent
21
25
  MODES = { single: "single", range: "range", multiple: "multiple" }.freeze
22
26
  CAPTION_LAYOUTS = %i[label dropdown].freeze
@@ -25,7 +29,7 @@ module PhlexKit
25
29
  def initialize(mode: :single, selected_date: nil, selected_dates: [], range_start: nil, range_end: nil,
26
30
  min_date: nil, max_date: nil, disabled_dates: [], week_numbers: false,
27
31
  caption_layout: :label, from_year: nil, to_year: nil,
28
- input_id: nil, date_format: "yyyy-MM-dd", **attrs)
32
+ input_id: nil, date_format: "yyyy-MM-dd", locale: nil, **attrs)
29
33
  @mode = MODES.fetch(mode.to_sym)
30
34
  @selected_date = selected_date
31
35
  @selected_dates = Array(selected_dates).map(&:to_s)
@@ -41,6 +45,7 @@ module PhlexKit
41
45
  @to_year = to_year
42
46
  @input_id = input_id
43
47
  @date_format = date_format
48
+ @locale = locale
44
49
  @attrs = attrs
45
50
  end
46
51
 
@@ -112,6 +117,8 @@ module PhlexKit
112
117
  data[:phlex_kit__calendar_selected_dates_value] = JSON.generate(@selected_dates) if @selected_dates.any?
113
118
  data[:phlex_kit__calendar_disabled_dates_value] = JSON.generate(@disabled_dates) if @disabled_dates.any?
114
119
  data[:phlex_kit__calendar_week_numbers_value] = "true" if @week_numbers
120
+ # Unset = the runtime's own locale (the controller passes undefined to Intl).
121
+ data[:phlex_kit__calendar_locale_value] = @locale if @locale
115
122
  # Seed the view on the selection so the grid opens on the right month.
116
123
  view_seed = @selected_date || @range_start || @selected_dates.first
117
124
  data[:phlex_kit__calendar_view_date_value] = view_seed.to_s if view_seed
@@ -41,6 +41,10 @@ export default class extends Controller {
41
41
  // toISOString() is UTC (wrong day for non-UTC users near midnight).
42
42
  viewDate: { type: String, default: "" },
43
43
  format: { type: String, default: "yyyy-MM-dd" },
44
+ // BCP 47 tag for every Intl call below. Unset = "" = undefined to Intl,
45
+ // i.e. the runtime's own locale (the server markup is English only as the
46
+ // pre-JS baseline, so the grid localizes itself on first render).
47
+ locale: { type: String, default: "" },
44
48
  };
45
49
  static outlets = ["phlex-kit--calendar-input"];
46
50
 
@@ -274,6 +278,34 @@ export default class extends Controller {
274
278
  // `focusDay` overrides the day to refocus after re-render: on a keyboard
275
279
  // month cross the target day lives in the NEW grid, not the pre-render
276
280
  // activeElement, so onKeydown passes it explicitly.
281
+ // undefined (not "") is what Intl wants for "use the runtime default".
282
+ locale() {
283
+ return this.localeValue || undefined;
284
+ }
285
+
286
+ // The weekday <th>s are cloned verbatim from a server template and the month
287
+ // dropdown's <option>s are server-rendered — both English. Relabel them from
288
+ // Intl on every render so the whole chrome speaks one language.
289
+ localizeChrome() {
290
+ const heads = [...this.calendarTarget.querySelectorAll(".pk-calendar-weekday")].filter(
291
+ (th) => !th.classList.contains("pk-calendar-weeknumber-head"),
292
+ );
293
+ // The server row runs Monday..Sunday; 2024-01-01 was a Monday.
294
+ heads.forEach((th, index) => {
295
+ const day = new Date(2024, 0, 1 + index);
296
+ const name = day.toLocaleDateString(this.locale(), { weekday: "long" });
297
+ th.setAttribute("aria-label", name);
298
+ th.textContent = day.toLocaleDateString(this.locale(), { weekday: "short" }).slice(0, 2);
299
+ });
300
+
301
+ if (!this.hasMonthSelectTarget) return;
302
+ [...this.monthSelectTarget.options].forEach((option) => {
303
+ const month = Number(option.value);
304
+ if (!Number.isFinite(month)) return;
305
+ option.textContent = new Date(2024, month, 1).toLocaleDateString(this.locale(), { month: "long" });
306
+ });
307
+ }
308
+
277
309
  updateCalendar(focusDay = null) {
278
310
  if (this.hasTitleTarget) {
279
311
  this.titleTarget.textContent = this.monthAndYear();
@@ -288,6 +320,7 @@ export default class extends Controller {
288
320
  ? document.activeElement.dataset?.day
289
321
  : null);
290
322
  this.calendarTarget.innerHTML = this.calendarHTML();
323
+ this.localizeChrome();
291
324
  this.ensureGridTabStop(focusedDay);
292
325
  }
293
326
 
@@ -419,7 +452,7 @@ export default class extends Controller {
419
452
  dayDate: day.getDate(),
420
453
  // full human-readable date for the button's accessible name — a bare
421
454
  // day number ("14") is meaningless to a screen reader
422
- dayLabel: day.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
455
+ dayLabel: day.toLocaleDateString(this.locale(), { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
423
456
  state: this.dayState(day),
424
457
  };
425
458
 
@@ -499,10 +532,10 @@ export default class extends Controller {
499
532
  .filter(Boolean);
500
533
  }
501
534
 
535
+ // One Intl call rather than `${month} ${year}` so locales that order the
536
+ // parts differently (ja-JP → "2026年1月") come out right, not just translated.
502
537
  monthAndYear() {
503
- const month = this.viewDate().toLocaleString("en-US", { month: "long" });
504
- const year = this.viewDate().getFullYear();
505
- return `${month} ${year}`;
538
+ return this.viewDate().toLocaleDateString(this.locale(), { month: "long", year: "numeric" });
506
539
  }
507
540
 
508
541
  selectedDate() {
@@ -592,8 +625,8 @@ export default class extends Controller {
592
625
  const hours = date.getHours();
593
626
  const minutes = date.getMinutes();
594
627
  const seconds = date.getSeconds();
595
- const dayOfWeek = date.toLocaleString("en-US", { weekday: "long" });
596
- const monthName = date.toLocaleString("en-US", { month: "long" });
628
+ const dayOfWeek = date.toLocaleString(this.locale(), { weekday: "long" });
629
+ const monthName = date.toLocaleString(this.locale(), { month: "long" });
597
630
  const daySuffix = this.getDaySuffix(day);
598
631
 
599
632
  const map = {
@@ -7,7 +7,15 @@ module PhlexKit
7
7
  # Upstream's fuse.js fuzzy search is replaced with a dependency-free substring
8
8
  # match in the controller. Tailwind → vanilla `.pk-command*` (command.css).
9
9
  class Command < BaseComponent
10
- def initialize(**attrs)
10
+ # Announcement templates for the live region — the only strings the
11
+ # controller writes itself (CommandEmpty is server-rendered, so it
12
+ # localizes with the rest of your markup). %{count} is interpolated.
13
+ DEFAULT_RESULTS_FORMAT = "%{count} result(s)"
14
+ DEFAULT_NO_RESULTS_TEXT = "No results"
15
+
16
+ def initialize(results_format: DEFAULT_RESULTS_FORMAT, no_results_text: DEFAULT_NO_RESULTS_TEXT, **attrs)
17
+ @results_format = results_format
18
+ @no_results_text = no_results_text
11
19
  @attrs = attrs
12
20
  end
13
21
 
@@ -21,12 +29,17 @@ module PhlexKit
21
29
  private
22
30
 
23
31
  # Screen-reader announcement of the filtered result count — the controller
24
- # writes "N results" / "No results" into it from filter().
32
+ # writes the results_format / no_results_text strings into it from
33
+ # filter(), interpolating %{count}.
25
34
  def live_region
26
35
  div(
27
36
  class: "pk-sr-only",
28
37
  aria: { live: "polite" },
29
- data: { phlex_kit__command_target: "liveRegion" }
38
+ data: {
39
+ phlex_kit__command_target: "liveRegion",
40
+ results_format: @results_format,
41
+ no_results_text: @no_results_text
42
+ }
30
43
  )
31
44
  end
32
45
  end
@@ -262,8 +262,13 @@ export default class extends Controller {
262
262
  if (count === null) {
263
263
  this.liveRegionTarget.textContent = "";
264
264
  } else {
265
+ // Templates come from the server (Command's results_format: /
266
+ // no_results_text: kwargs) — the defaults mirror command.rb's.
267
+ const data = this.liveRegionTarget.dataset;
265
268
  this.liveRegionTarget.textContent =
266
- count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"}`;
269
+ count === 0
270
+ ? data.noResultsText || "No results"
271
+ : (data.resultsFormat || "%{count} result(s)").replace("%{count}", count);
267
272
  }
268
273
  }
269
274
 
@@ -164,7 +164,7 @@ export default class extends Controller {
164
164
  // trigger still counts). Escape stays global while the menu is open (APG).
165
165
  if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
166
166
 
167
- const items = this.items()
167
+ const items = this.rovingItems()
168
168
  const index = items.indexOf(document.activeElement)
169
169
  switch (e.key) {
170
170
  case "Escape":
@@ -254,4 +254,18 @@ export default class extends Controller {
254
254
  (el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0
255
255
  )
256
256
  }
257
+
258
+ // The list ArrowUp/Down/Home/End rove — level-aware. A submenu reveals on
259
+ // :focus-within, so focusing a sub-trigger makes its rows visible and
260
+ // items() would pick them up; roving would then dive into the submenu on
261
+ // ArrowDown instead of moving to the next PARENT item (APG reserves
262
+ // ArrowRight/enterKey for entering). When focus is inside a sub panel, rove
263
+ // that panel's own rows; otherwise rove the top-level rows, excluding any
264
+ // sub-content the reveal exposed. Mirrored in menubar/dropdown (shared model).
265
+ rovingItems() {
266
+ const rows = this.items()
267
+ const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
268
+ if (sub) return rows.filter((el) => el.closest(".pk-context-menu-sub-content") === sub)
269
+ return rows.filter((el) => !el.closest(".pk-context-menu-sub-content"))
270
+ }
257
271
  }
@@ -20,6 +20,7 @@ module PhlexKit
20
20
  placeholder: "Select a date",
21
21
  selected_date: value,
22
22
  date_format: "yyyy-MM-dd",
23
+ locale: nil,
23
24
  input_attrs: {},
24
25
  calendar_attrs: {},
25
26
  trigger_attrs: {},
@@ -31,6 +32,7 @@ module PhlexKit
31
32
  @label = label
32
33
  @selected_date = selected_date
33
34
  @date_format = date_format
35
+ @locale = locale
34
36
  # Seed the input with the same date_format the calendar controller
35
37
  # writes — a bare `selected_date.to_s` (ISO) would mismatch the format
36
38
  # until the first interaction.
@@ -65,7 +67,8 @@ module PhlexKit
65
67
  end
66
68
  end
67
69
  render PopoverContent.new(**@content_attrs) do
68
- render Calendar.new(input_id: input_selector, selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
70
+ render Calendar.new(input_id: input_selector, selected_date: @selected_date, date_format: @date_format,
71
+ locale: @locale, **@calendar_attrs)
69
72
  end
70
73
  end
71
74
  end
@@ -164,7 +164,7 @@ export default class extends Controller {
164
164
  // while the menu is open (APG).
165
165
  if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
166
166
 
167
- const items = this.#items();
167
+ const items = this.#rovingItems();
168
168
  if (items.length === 0) return;
169
169
  const index = items.indexOf(document.activeElement);
170
170
 
@@ -245,6 +245,20 @@ export default class extends Controller {
245
245
  );
246
246
  }
247
247
 
248
+ // The list ArrowUp/Down/Home/End rove — level-aware. A submenu reveals on
249
+ // :focus-within, so focusing a sub-trigger makes its rows visible and
250
+ // #items() would pick them up; roving would then dive into the submenu on
251
+ // ArrowDown instead of moving to the next PARENT item (APG reserves
252
+ // ArrowRight/enterKey for entering). When focus is inside a sub panel, rove
253
+ // that panel's own rows; otherwise rove the top-level rows, excluding any
254
+ // sub-content the reveal exposed. Mirrored in menubar/context (shared model).
255
+ #rovingItems() {
256
+ const rows = this.#items();
257
+ const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
258
+ if (sub) return rows.filter((el) => el.closest(".pk-dropdown-menu-sub-content") === sub);
259
+ return rows.filter((el) => !el.closest(".pk-dropdown-menu-sub-content"));
260
+ }
261
+
248
262
  #addEventListeners() {
249
263
  document.addEventListener("keydown", this.boundHandleKeydown);
250
264
  }
@@ -210,7 +210,7 @@ export default class extends Controller {
210
210
  }
211
211
  return
212
212
  }
213
- const items = this.items(this.openMenu)
213
+ const items = this.rovingItems()
214
214
  const index = items.indexOf(document.activeElement)
215
215
  switch (e.key) {
216
216
  case "Escape":
@@ -333,6 +333,22 @@ export default class extends Controller {
333
333
  return menu.querySelector("[role=\"menu\"], .pk-menubar-content, .pk-navigation-menu-content")
334
334
  }
335
335
 
336
+ // The list ArrowUp/Down/Home/End rove — level-aware. A submenu reveals on
337
+ // :focus-within, so focusing a sub-trigger makes its rows visible and
338
+ // items() would pick them up; roving would then dive into the submenu on
339
+ // ArrowDown instead of moving to the next PARENT item (APG reserves
340
+ // ArrowRight/enterKey for entering). When focus is inside a sub panel, rove
341
+ // that panel's own rows; otherwise rove the top-level rows, excluding any
342
+ // sub-content the reveal exposed. Mirrored in dropdown/context (shared model).
343
+ rovingItems() {
344
+ const menu = this.openMenu
345
+ if (!menu) return []
346
+ const rows = this.items(menu)
347
+ const sub = document.activeElement?.closest(".pk-menubar-sub-content")
348
+ if (sub) return rows.filter((el) => el.closest(".pk-menubar-sub-content") === sub)
349
+ return rows.filter((el) => !el.closest(".pk-menubar-sub-content"))
350
+ }
351
+
336
352
  items(menu) {
337
353
  const panel = this.panel(menu)
338
354
  if (!panel) return []
@@ -13,7 +13,14 @@ module PhlexKit
13
13
 
14
14
  def view_template(&)
15
15
  classes = [ "pk-popover-content", fetch_option(ALIGNS, @align, :align) ].compact.join(" ")
16
- div(**mix({ class: classes, popover: "auto", data: { phlex_kit__popover_target: "content", state: "closed" } }, @attrs), &)
16
+ # `popover:` is a named default, not a merged attr: `mix` joins duplicate
17
+ # attrs (a caller's `popover: "manual"` would fuse into "auto manual",
18
+ # which the browser normalizes to manual — killing the native light
19
+ # dismiss/Escape this controller relies on). Skip the default when the
20
+ # caller sets it (mirrors MenubarContent).
21
+ base = { class: classes, data: { phlex_kit__popover_target: "content", state: "closed" } }
22
+ base[:popover] = "auto" unless attr_set?(:popover)
23
+ div(**mix(base, @attrs), &)
17
24
  end
18
25
  end
19
26
  end
@@ -16,6 +16,10 @@ export default class extends Controller {
16
16
  const invoker = this.triggerTarget.querySelector("button")
17
17
  if (invoker) {
18
18
  invoker.popoverTargetElement = this.contentTarget
19
+ // popoverTargetElement gives native aria-expanded but not haspopup —
20
+ // shadcn/Radix Popover.Trigger ships aria-haspopup="dialog", so add it
21
+ // (leave a caller-set value alone) to match the button-less path below.
22
+ if (!invoker.hasAttribute("aria-haspopup")) invoker.setAttribute("aria-haspopup", "dialog")
19
23
  } else {
20
24
  // Button-less fallback: keyboard toggling and popup semantics don't
21
25
  // come for free like they do with popoverTargetElement — wire Enter
@@ -38,6 +38,22 @@
38
38
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
39
39
  }
40
40
  .pk-radio[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
41
+ /* Invalid + keyboard focus: the always-on invalid ring above would otherwise
42
+ swallow the equal-specificity :focus-visible rule, leaving no visible focus
43
+ change (and the theme-scoped invalid arms below beat it outright in light).
44
+ Keep the red border (invalid stays legible) but switch the ring to the
45
+ standard focus color — mirrors checkbox.css. */
46
+ .pk-radio[aria-invalid="true"]:focus-visible,
47
+ :root[data-theme="light"] .pk-radio[aria-invalid="true"]:focus-visible {
48
+ border-color: var(--pk-red);
49
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
50
+ }
51
+ @media (prefers-color-scheme: light) {
52
+ :root[data-theme="system"] .pk-radio[aria-invalid="true"]:focus-visible {
53
+ border-color: var(--pk-red);
54
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
55
+ }
56
+ }
41
57
  .pk-radio:disabled { cursor: not-allowed; opacity: .5; }
42
58
  :root[data-theme="light"] .pk-radio:not(:checked) { background: transparent; }
43
59
  :root[data-theme="light"] .pk-radio[aria-invalid="true"] {
@@ -39,7 +39,7 @@ module PhlexKit
39
39
  # keydown.esc rides on the root (not only the items) so Escape closes
40
40
  # the [popover=manual] panel with focus on the trigger too; handleEsc
41
41
  # no-ops while closed.
42
- action: "click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc focusout->phlex-kit--select#onFocusout"
42
+ action: "mousedown@window->phlex-kit--select#onMousedownOutside click@window->phlex-kit--select#clickOutside keydown.esc->phlex-kit--select#handleEsc focusout->phlex-kit--select#onFocusout"
43
43
  }
44
44
  }, @attrs), &block)
45
45
  end
@@ -1,5 +1,20 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
+ // One-shot capture listener swallowing the click an outside mousedown is about
4
+ // to produce (see onMousedownOutside; same helper in dropdown/context/menubar
5
+ // — duplicated per controller by design). Select is a MODAL menu: the
6
+ // dismissing outside click ONLY dismisses, never also acts on what sits under
7
+ // the pointer. Armed at MOUSEDOWN because for a focusable click target the same
8
+ // gesture's focusout closes the panel before the click fires (a click-time
9
+ // check would already see it closed and skip the swallow — audit round 8).
10
+ const swallowClick = (ev) => {
11
+ ev.preventDefault();
12
+ ev.stopPropagation();
13
+ };
14
+ function armSwallowClick() {
15
+ window.addEventListener("click", swallowClick, { once: true, capture: true });
16
+ }
17
+
3
18
  // Ported from ruby_ui's phlex-kit--select controller, minus the
4
19
  // @floating-ui/dom dependency: the panel is a native [popover=manual] in the
5
20
  // top layer, anchor-positioned with viewport-edge flipping by select.css
@@ -53,6 +68,12 @@ export default class extends Controller {
53
68
  const newValue = item.dataset.value;
54
69
 
55
70
  this.inputTarget.value = newValue;
71
+ // Also set the value ATTRIBUTE, not just the .value property: a Turbo
72
+ // snapshot restore clones the DOM (dropping the dirty property) and would
73
+ // otherwise revert the hidden input to its server value while the label
74
+ // and aria-selected still show the chosen item — submitting the wrong
75
+ // value behind a correct-looking UI. (re-derive-from-live-truth rule.)
76
+ this.inputTarget.setAttribute("value", newValue);
56
77
  this.valueTarget.innerText = item.innerText;
57
78
 
58
79
  this.dispatchOnChange(oldValue, newValue);
@@ -162,6 +183,17 @@ export default class extends Controller {
162
183
  this.itemTargets.forEach((item) => item.removeAttribute("aria-current"));
163
184
  }
164
185
 
186
+ // Modal dismiss: arm the swallow at mousedown so the outside click that
187
+ // dismisses an open select doesn't ALSO activate a focusable control under
188
+ // the pointer (the same gesture's focusout closes the panel before the
189
+ // click, so clickOutside below would already see it closed). Mirrors
190
+ // dropdown/context/menubar.
191
+ onMousedownOutside(event) {
192
+ if (!this.contentTarget.matches(":popover-open")) return;
193
+ if (this.element.contains(event.target)) return;
194
+ armSwallowClick();
195
+ }
196
+
165
197
  clickOutside(event) {
166
198
  if (!this.contentTarget.matches(":popover-open")) return;
167
199
  if (this.element.contains(event.target)) return;
@@ -6,7 +6,12 @@ module PhlexKit
6
6
  end
7
7
 
8
8
  def view_template(&block)
9
- th(**mix({ class: "pk-table-head" }, @attrs), &block)
9
+ # scope="col" is the common case (header cells sit in a TableHeader row);
10
+ # a generated default, not a merged attr — `mix` would fuse a caller's
11
+ # `scope: "row"` into "col row" — so skip it when the caller sets scope.
12
+ base = { class: "pk-table-head" }
13
+ base[:scope] = "col" unless attr_set?(:scope)
14
+ th(**mix(base, @attrs), &block)
10
15
  end
11
16
  end
12
17
  end
@@ -88,6 +88,7 @@ module PhlexKit
88
88
  data: { controller: "phlex-kit--toggle-group",
89
89
  phlex_kit__toggle_group_type_value: @type.to_s,
90
90
  phlex_kit__toggle_group_name_value: @name.to_s,
91
+ phlex_kit__toggle_group_disabled_value: @disabled.to_s,
91
92
  orientation: @orientation.to_s, spacing: @spacing.to_s } }
92
93
  # spacing: N gaps the items by N × .25rem (Tailwind-style scale) via the
93
94
  # custom property toggle_group.css reads. Trailing ";" so a caller
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="phlex-kit--toggle-group"
4
4
  export default class extends Controller {
5
5
  static targets = ["item", "input"]
6
- static values = { type: String, name: String }
6
+ static values = { type: String, name: String, disabled: Boolean }
7
7
 
8
8
  connect() { this.reconcile() }
9
9
 
@@ -100,6 +100,11 @@ export default class extends Controller {
100
100
  input.type = "hidden"
101
101
  input.name = name
102
102
  input.value = value
103
+ // A disabled group's hidden input is server-rendered `disabled` so it
104
+ // won't submit (toggle_group.rb). rebuildInputs() removes that server
105
+ // input and recreates it here on connect() — carry the disabled flag or
106
+ // the group would start submitting its value post-hydration.
107
+ if (this.disabledValue) input.disabled = true
103
108
  input.setAttribute("data-phlex-kit--toggle-group-target", "input")
104
109
  return input
105
110
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexKit
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.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.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kennedy