phlex-reactive 0.13.3 → 0.13.4

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.
@@ -1524,16 +1524,18 @@ function persistClearRoot(root) {
1524
1524
  // Frozen so nothing can be registered into it at runtime — extending the
1525
1525
  // vocabulary is a gem change, not an app hook.
1526
1526
  const CLIENT_OPS = Object.freeze({
1527
- show: (el, args) => setHidden(el, false, args),
1528
- hide: (el, args) => setHidden(el, true, args),
1529
- toggle: (el, args) => setHidden(el, !el.hidden, args),
1527
+ show: (el, args, resolveTargets) => setVisibility(el, false, args, resolveTargets),
1528
+ hide: (el, args, resolveTargets) => setVisibility(el, true, args, resolveTargets),
1529
+ toggle: (el, args, resolveTargets) => setVisibility(el, !el.hidden, args, resolveTargets),
1530
1530
  add_class: (el, args) => el.classList.add(...(args.classes ?? [])),
1531
1531
  remove_class: (el, args) => el.classList.remove(...(args.classes ?? [])),
1532
1532
  toggle_class: (el, args) => (args.classes ?? []).forEach((c) => el.classList.toggle(c)),
1533
1533
 
1534
1534
  // Attribute ops (issue #96), interpret-time allowlisted. set_attr writes the
1535
1535
  // (already-stringified) value; toggle_attr adds a missing attr (value "") or
1536
- // removes a present one; remove_attr removes it. A refused name warns + skips.
1536
+ // removes a present one — or, given values [on, off] (issue #271), flips
1537
+ // between them (an absent attr becomes `on`); remove_attr removes it. A
1538
+ // refused name warns + skips.
1537
1539
  set_attr: (el, args) => {
1538
1540
  if (guardAttr(args.name)) el.setAttribute(args.name, args.value ?? "")
1539
1541
  },
@@ -1542,7 +1544,10 @@ const CLIENT_OPS = Object.freeze({
1542
1544
  },
1543
1545
  toggle_attr: (el, args) => {
1544
1546
  if (!guardAttr(args.name)) return
1545
- if (el.hasAttribute(args.name)) el.removeAttribute(args.name)
1547
+ if (Array.isArray(args.values)) {
1548
+ const [on, off] = args.values
1549
+ el.setAttribute(args.name, el.getAttribute(args.name) === on ? off : on)
1550
+ } else if (el.hasAttribute(args.name)) el.removeAttribute(args.name)
1546
1551
  else el.setAttribute(args.name, "")
1547
1552
  },
1548
1553
 
@@ -1642,6 +1647,18 @@ function setHidden(el, hidden, args) {
1642
1647
  else el.hidden = hidden
1643
1648
  }
1644
1649
 
1650
+ // show/hide/toggle (issue #271): flip visibility, then mirror the INTENDED
1651
+ // state into the `expanded:` target's aria-expanded — computed before the flip,
1652
+ // so a transition never delays or races it. The expanded target resolves with
1653
+ // the op's own scoping (root-scoped, or document-wide under global: true).
1654
+ function setVisibility(el, hidden, args, resolveTargets) {
1655
+ setHidden(el, hidden, args)
1656
+ if (args?.expanded == null || typeof resolveTargets !== "function") return
1657
+ for (const target of resolveTargets({ ...args, to: args.expanded })) {
1658
+ target.setAttribute?.("aria-expanded", String(!hidden))
1659
+ }
1660
+ }
1661
+
1645
1662
  // The interpret-time attribute guard: refuse (warn + skip) a name off the
1646
1663
  // allowlist. Returns true when the op may proceed.
1647
1664
  function guardAttr(name) {
@@ -1947,6 +1964,168 @@ function parseOps(raw) {
1947
1964
  }
1948
1965
  }
1949
1966
 
1967
+ // --- on_client binding records (issue #271) -----------------------------------
1968
+ // Each on_client call emits ONE record ({on, ops, window?, outside?, confirm?,
1969
+ // confirmWhen?}); mix space-joins several onto one element (spaces inside a
1970
+ // record ride as \u0020, so the join is unambiguous). Stimulus hands every
1971
+ // runOps descriptor on an element the SAME event.params, so runOps selects the
1972
+ // record(s) whose descriptor matches the firing event: event.type, the key
1973
+ // filter, and window-boundness.
1974
+
1975
+ // Stimulus's default keyMappings (letters and digits map to themselves). A
1976
+ // custom Stimulus schema is not mirrored — an unknown filter never matches.
1977
+ const KEY_FILTER_MAP = Object.freeze({
1978
+ enter: "Enter",
1979
+ tab: "Tab",
1980
+ esc: "Escape",
1981
+ space: " ",
1982
+ up: "ArrowUp",
1983
+ down: "ArrowDown",
1984
+ left: "ArrowLeft",
1985
+ right: "ArrowRight",
1986
+ home: "Home",
1987
+ end: "End",
1988
+ page_up: "PageUp",
1989
+ page_down: "PageDown",
1990
+ })
1991
+ const KEY_FILTER_MODIFIERS = Object.freeze(["meta", "ctrl", "alt", "shift"])
1992
+
1993
+ // Stimulus's rule: the four modifiers must match EXACTLY (ctrl+k is not k);
1994
+ // the remaining token is compared to event.key case-insensitively. A
1995
+ // non-keyboard event (no event.key) checks the modifiers only. `keyMappings`
1996
+ // is the app's Stimulus schema table (custom keys included) when the
1997
+ // controller has one; the default table is the fallback.
1998
+ function keyFilterMatches(filter, event, keyMappings) {
1999
+ const parts = filter.split("+")
2000
+ for (const mod of KEY_FILTER_MODIFIERS) {
2001
+ if (parts.includes(mod) !== Boolean(event[`${mod}Key`])) return false
2002
+ }
2003
+ const token = parts.find((part) => !KEY_FILTER_MODIFIERS.includes(part))
2004
+ if (token === undefined || typeof event.key !== "string") return true
2005
+ const key =
2006
+ keyMappings && Object.hasOwn(keyMappings, token)
2007
+ ? keyMappings[token]
2008
+ : Object.hasOwn(KEY_FILTER_MAP, token)
2009
+ ? KEY_FILTER_MAP[token]
2010
+ : /^[a-z0-9]$/.test(token)
2011
+ ? token
2012
+ : null
2013
+ return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
2014
+ }
2015
+
2016
+ // Parse data-reactive-ops-param into binding records. Stimulus typecasts a
2017
+ // lone record to an object; several records stay a string (JSON.parse fails on
2018
+ // the join) and are split here. A LEGACY [[op, args]] list (array or JSON
2019
+ // string — a hand-built attr) becomes one record flagged `legacy`: it has no
2020
+ // `on`, so it always matches, and runOps reads its flags from event.params. A
2021
+ // malformed piece warns and is skipped; its siblings still run (default-deny).
2022
+ function parseBindingRecords(raw) {
2023
+ if (Array.isArray(raw)) return [{ ops: raw, legacy: true }]
2024
+ if (raw && typeof raw === "object") return [raw]
2025
+ if (typeof raw !== "string") return []
2026
+ const text = raw.trim()
2027
+ if (text.startsWith("[")) return [{ ops: parseOps(text), legacy: true }]
2028
+ const records = []
2029
+ for (const piece of text.split(" ")) {
2030
+ if (piece === "") continue
2031
+ let record = null
2032
+ try {
2033
+ record = JSON.parse(piece)
2034
+ } catch {
2035
+ record = null
2036
+ }
2037
+ if (record && typeof record === "object" && !Array.isArray(record)) records.push(record)
2038
+ else console.warn(`[phlex-reactive] malformed on_client binding record ${JSON.stringify(piece)} — skipped`)
2039
+ }
2040
+ return records
2041
+ }
2042
+
2043
+ // Does this record's descriptor match the firing event? The window check
2044
+ // matters for an element carrying both `click` and `click@window`: one inside
2045
+ // click reaches both listeners, and only currentTarget tells them apart. The
2046
+ // `window != null` guard keeps a window-less harness from classifying every
2047
+ // undefined currentTarget as window-bound. Only a LEGACY record (no `on` by
2048
+ // construction) matches everything; any other record without a descriptor is
2049
+ // malformed and never matches (default-deny). `keyMappings === null` skips the
2050
+ // key filter (the caller's single-candidate shortcut).
2051
+ function bindingMatches(record, event, keyMappings) {
2052
+ if (record.legacy) return true
2053
+ const on = record.on
2054
+ if (typeof on !== "string" || on === "") return false
2055
+ const dot = on.indexOf(".")
2056
+ if (event.type !== (dot < 0 ? on : on.slice(0, dot))) return false
2057
+ const win = globalThis.window
2058
+ const windowBound = win != null && event.currentTarget === win
2059
+ if (Boolean(record.window) !== windowBound) return false
2060
+ return dot < 0 || keyMappings === null || keyFilterMatches(on.slice(dot + 1), event, keyMappings)
2061
+ }
2062
+
2063
+ // A `once: true` record (PR #272) is spent after its first run. Stimulus
2064
+ // removes only that binding's own listener; a regular same-event sibling on
2065
+ // the element keeps calling runOps with BOTH records, so the spent one is
2066
+ // skipped here. Spent state belongs to the TRIGGER element (a WeakMap, so it
2067
+ // is collected with it): a morph/stream that swaps in a fresh element starts
2068
+ // fresh, and a byte-identical sibling trigger has its own. A window-bound
2069
+ // binding's currentTarget is the window, so there the element's ops attr joins
2070
+ // the key (a re-rendered window-bound once trigger with the same markup stays
2071
+ // spent — a known limit).
2072
+ const spentOnceBindings = new WeakMap()
2073
+ function onceBindingSpent(controller, event, record) {
2074
+ if (!record.once) return false
2075
+ const owner = event.currentTarget ?? controller.element
2076
+ let byOwner = spentOnceBindings.get(controller)
2077
+ if (!byOwner) {
2078
+ byOwner = new WeakMap()
2079
+ spentOnceBindings.set(controller, byOwner)
2080
+ }
2081
+ let spent = byOwner.get(owner)
2082
+ if (!spent) {
2083
+ spent = new Set()
2084
+ byOwner.set(owner, spent)
2085
+ }
2086
+ const raw = event.params?.ops
2087
+ const attr = owner === globalThis.window ? `${typeof raw === "string" ? raw : JSON.stringify(raw ?? null)}|` : ""
2088
+ const key = attr + JSON.stringify(record)
2089
+ if (spent.has(key)) return true
2090
+ spent.add(key)
2091
+ return false
2092
+ }
2093
+
2094
+ // Two identical descriptors on one element (two mix-ed on_client calls with
2095
+ // the same event) are two Stimulus bindings, so runOps runs twice for ONE
2096
+ // event. The first call runs every matching record; a repeat is a no-op.
2097
+ // "Repeat" is (controller, listener target, the element's ops attr): Stimulus
2098
+ // walks EVERY binding of one window listener with the SAME event object, so
2099
+ // keying on currentTarget alone (= window) would drop every other root's
2100
+ // window-bound binding — and a sibling element's within one root. The ops attr
2101
+ // is the per-element key (a lone record is typecast to a fresh object per
2102
+ // binding, so it is compared by its JSON). Two elements in one root carrying
2103
+ // byte-identical window-bound records run once — the same ops, applied once.
2104
+ const ranBindings = new WeakMap()
2105
+ function bindingsAlreadyRan(event, controller) {
2106
+ if (event === null || typeof event !== "object") return false
2107
+ const raw = event.params?.ops
2108
+ const key = typeof raw === "string" ? raw : JSON.stringify(raw ?? null)
2109
+ let byController = ranBindings.get(event)
2110
+ if (!byController) {
2111
+ byController = new WeakMap()
2112
+ ranBindings.set(event, byController)
2113
+ }
2114
+ let byTarget = byController.get(controller)
2115
+ if (!byTarget) {
2116
+ byTarget = new Map()
2117
+ byController.set(controller, byTarget)
2118
+ }
2119
+ let keys = byTarget.get(event.currentTarget)
2120
+ if (!keys) {
2121
+ keys = new Set()
2122
+ byTarget.set(event.currentTarget, keys)
2123
+ }
2124
+ if (keys.has(key)) return true
2125
+ keys.add(key)
2126
+ return false
2127
+ }
2128
+
1950
2129
  // The TEXT reading of a compute control (issue #262) — what a :string input
1951
2130
  // hands the reducer and what the identity/cross-root mirrors paint. A checkbox
1952
2131
  // reads its CHECKED STATE ("true"/"false", the strings reactive_show compares
@@ -2067,7 +2246,7 @@ function applyOps(list, resolveTargets, onZeroTargets) {
2067
2246
  }
2068
2247
  const targets = resolveTargets(args)
2069
2248
  if (targets.length === 0 && onZeroTargets) onZeroTargets(name, args)
2070
- for (const el of targets) CLIENT_OPS[name](el, args)
2249
+ for (const el of targets) CLIENT_OPS[name](el, args, resolveTargets)
2071
2250
  }
2072
2251
  }
2073
2252
 
@@ -2606,11 +2785,33 @@ export default class extends Controller {
2606
2785
  // the component resets whatever they toggled (by design — a signed action
2607
2786
  // owns state that must survive re-renders).
2608
2787
  runOps(event) {
2609
- const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
2788
+ if (bindingsAlreadyRan(event, this)) return
2789
+ const params = event.params ?? {}
2610
2790
  // The trigger element on_client was spread onto (issue #222 ctx: { el }),
2611
2791
  // captured now — currentTarget resets before the confirm resolver's microtask.
2612
2792
  const trigger = event.currentTarget ?? event.target
2793
+ // Issue #271: run only the binding record(s) whose descriptor fired. A
2794
+ // legacy [[op, args]] attr reads its flags from the element-wide params.
2795
+ const records = parseBindingRecords(params.ops)
2796
+ // Stimulus already applied the firing descriptor's key filter (custom
2797
+ // schema keys included), so a lone candidate on type + window-boundness
2798
+ // runs without a second key check; several same-type candidates are told
2799
+ // apart by their key filters, read through the app's Stimulus schema.
2800
+ const candidates = records.filter((record) => bindingMatches(record, event, null))
2801
+ const matching =
2802
+ candidates.length <= 1
2803
+ ? candidates
2804
+ : candidates.filter((record) => bindingMatches(record, event, this.application?.schema?.keyMappings))
2805
+ if (matching.length === 0 && records.length > 0) return this.#warnNoBinding(event)
2806
+ for (const record of matching) {
2807
+ if (onceBindingSpent(this, event, record)) continue
2808
+ this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2809
+ }
2810
+ }
2613
2811
 
2812
+ // One on_client binding (issue #95): its outside guard, preventDefault rule,
2813
+ // confirm gate, then its ops — every flag read from the binding itself.
2814
+ #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
2614
2815
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
2615
2816
  // outside: trigger is a COMPLETE no-op for events inside this root, before
2616
2817
  // preventDefault and before any op runs.
@@ -2648,6 +2849,17 @@ export default class extends Controller {
2648
2849
  })
2649
2850
  }
2650
2851
 
2852
+ // Issue #271: runOps fired but no record matched the event — a hand-edited
2853
+ // attr or a descriptor the matcher doesn't know. Verbose gate only, deduped.
2854
+ #warnNoBinding(event) {
2855
+ if (!this.#verboseEnabled()) return
2856
+ const key = `no-binding|${event.type}|${this.element?.id || "?"}`
2857
+ if (zeroTargetAlreadyWarned(key)) return
2858
+ console.warn(
2859
+ `[phlex-reactive] runOps on #${this.element?.id || "?"} found no on_client binding matching a "${event.type}" event — nothing ran`,
2860
+ )
2861
+ }
2862
+
2651
2863
  // Dirty tracking (issue #103). Wired by reactive_field(dirty: true) /
2652
2864
  // reactive_root(track_dirty: true): an `input` on an owned field runs a FULL
2653
2865
  // re-scan of every field this root owns. NO round trip, NO shipped state — the
@@ -2882,12 +3094,25 @@ export default class extends Controller {
2882
3094
  // CLICKING IT (so its own on(:select) reactive trigger fires — selection stays
2883
3095
  // a signed action); Escape clears. Ephemeral highlight state lives on the DOM
2884
3096
  // (data-reactive-highlighted), never shipped to the client as trusted state.
3097
+ //
3098
+ // Issue #271: a container spread with reactive_listnav(focus: true) carries
3099
+ // data-reactive-listnav-focus-param="true" — ROVING-FOCUS mode for a
3100
+ // role=menu: the same moves shift real focus among the items instead of a
3101
+ // highlight, and Home/End (listnavFirst/listnavLast) jump to the edges.
2885
3102
  listnavNext(event) {
2886
- this.#moveHighlight(event, +1)
3103
+ this.#listnavMove(event, (current, length) => (current < 0 ? 0 : (current + 1) % length))
2887
3104
  }
2888
3105
 
2889
3106
  listnavPrev(event) {
2890
- this.#moveHighlight(event, -1)
3107
+ this.#listnavMove(event, (current, length) => (current < 0 ? length - 1 : (current - 1 + length) % length))
3108
+ }
3109
+
3110
+ listnavFirst(event) {
3111
+ this.#listnavMove(event, () => 0)
3112
+ }
3113
+
3114
+ listnavLast(event) {
3115
+ this.#listnavMove(event, (_current, length) => length - 1)
2891
3116
  }
2892
3117
 
2893
3118
  // Enter: activate the highlighted option (fires its reactive select). No-op if
@@ -2905,23 +3130,39 @@ export default class extends Controller {
2905
3130
  for (const el of this.#listnavOptions(event)) el.removeAttribute("data-reactive-highlighted")
2906
3131
  }
2907
3132
 
2908
- // Move the highlight by `step` (with wrap-around) among THIS root's options.
2909
- // preventDefault stops Arrow keys from moving the caret in the search input.
2910
- #moveHighlight(event, step) {
3133
+ // Move among THIS root's options: `pick(current, length)` returns the next
3134
+ // index (current is -1 when nothing is highlighted/focused). preventDefault
3135
+ // stops Arrow keys moving the caret in a search input, and the page scrolling
3136
+ // under a menu. Highlight mode writes data-reactive-highlighted; focus mode
3137
+ // (issue #271) focuses the chosen item, current being the item that is or
3138
+ // contains document.activeElement.
3139
+ #listnavMove(event, pick) {
2911
3140
  const options = this.#listnavOptions(event)
2912
3141
  if (!options.length) return
2913
3142
  event.preventDefault()
2914
3143
 
2915
- const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
2916
- // From nothing: Down highlights the first option, Up the last.
2917
- const next = current < 0 ? (step > 0 ? 0 : options.length - 1) : (current + step + options.length) % options.length
3144
+ const focusMode = this.#listnavFocusMode(event)
3145
+ const active = globalThis.document?.activeElement
3146
+ const current = focusMode
3147
+ ? options.findIndex((el) => el === active || (active != null && el.contains?.(active)))
3148
+ : options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
3149
+ const chosen = options[pick(current, options.length)]
2918
3150
 
2919
- for (const el of options) el.removeAttribute("data-reactive-highlighted")
2920
- const chosen = options[next]
2921
- chosen.setAttribute("data-reactive-highlighted", "true")
3151
+ if (focusMode) {
3152
+ chosen.focus?.()
3153
+ } else {
3154
+ for (const el of options) el.removeAttribute("data-reactive-highlighted")
3155
+ chosen.setAttribute("data-reactive-highlighted", "true")
3156
+ }
2922
3157
  chosen.scrollIntoView?.({ block: "nearest" })
2923
3158
  }
2924
3159
 
3160
+ // Focus mode is read off the TRIGGER like the option selector (issue #271).
3161
+ #listnavFocusMode(event) {
3162
+ const trigger = event?.currentTarget ?? event?.target ?? this.element
3163
+ return trigger.getAttribute?.("data-reactive-listnav-focus-param") === "true"
3164
+ }
3165
+
2925
3166
  // The option elements this root owns (skips nested reactive roots, issue #15),
2926
3167
  // per the selector on data-reactive-listnav-option-param. The attr rides on the
2927
3168
  // TRIGGER element (the search input on(...) is spread onto), read from the