phlex-reactive 0.11.2 → 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +58 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +263 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +99 -0
- data/lib/phlex/reactive/test_helpers/system.rb +99 -11
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -640,6 +640,90 @@ module Phlex
|
|
|
640
640
|
}
|
|
641
641
|
end
|
|
642
642
|
|
|
643
|
+
# Tag-chip input (issue #203) — the composed combobox/tags primitive.
|
|
644
|
+
# Spread onto the ROOT (mix with reactive_root); it names the hidden
|
|
645
|
+
# field that stores the COMMA-JOINED value, and the generic controller
|
|
646
|
+
# maintains that field + the chip list entirely client-side. The value
|
|
647
|
+
# is FORM state (like text in an input), never component state — so
|
|
648
|
+
# add/remove round-trips nothing; the surrounding form submit carries
|
|
649
|
+
# the joined value and the server splits it (`tags.split(",")`).
|
|
650
|
+
#
|
|
651
|
+
# div(**mix(reactive_root, reactive_tags(:tags), reactive_filter(:tag_query))) do
|
|
652
|
+
# input(type: :hidden, **reactive_field(:tags), value: @tags.join(","))
|
|
653
|
+
# div(data: { reactive_tags_list: true }) { } # chips render here
|
|
654
|
+
# template(data: { reactive_tags_template: true }) do # the chip markup (server-owned)
|
|
655
|
+
# span(class: "chip") do
|
|
656
|
+
# span(data: { reactive_tag_text: true }) # the client writes the tag here (textContent)
|
|
657
|
+
# button(**reactive_tags_remove) { "×" } # the client fills the tag param per chip
|
|
658
|
+
# end
|
|
659
|
+
# end
|
|
660
|
+
# input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
|
|
661
|
+
# button(**reactive_tags_option("Ruby")) { "Ruby" } # preloaded options, filter narrows them
|
|
662
|
+
# end
|
|
663
|
+
#
|
|
664
|
+
# The chip list is a CLIENT PROJECTION of the hidden field: every sync
|
|
665
|
+
# rebuilds the chips by cloning the <template> (textContent writes only
|
|
666
|
+
# — never innerHTML), so the hidden value is the single source of truth
|
|
667
|
+
# and a server re-render/morph re-seeds cleanly. An option whose tag is
|
|
668
|
+
# already selected is hidden and marked data-reactive-tags-selected
|
|
669
|
+
# (reactive_filter keeps it hidden through re-filters). Tags dedupe
|
|
670
|
+
# case-insensitively, keeping the first casing.
|
|
671
|
+
#
|
|
672
|
+
# Composes with reactive_filter (type to narrow — same driving input)
|
|
673
|
+
# and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted
|
|
674
|
+
# option via its own tagsPick trigger, and reactive_tags_add only adds
|
|
675
|
+
# the TYPED text when nothing is highlighted — no double add).
|
|
676
|
+
def reactive_tags(field = nil)
|
|
677
|
+
if field.nil? || field.to_s.strip.empty?
|
|
678
|
+
raise ArgumentError, "reactive_tags needs a field name — reactive_tags(:tags)"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Compile the field to a scoped [name="…"] selector, the reactive_filter
|
|
682
|
+
# convention — so the hidden field written via reactive_field(:tags)
|
|
683
|
+
# resolves under reactive_scope too.
|
|
684
|
+
{ data: { reactive_tags_field: %([name="#{scoped_field_name(field)}"]) } }
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# The Enter-to-add trigger for the tags query input (issue #203) — a
|
|
688
|
+
# CLIENT-ONLY keyboard action (no dispatch descriptor, no POST). Mix it
|
|
689
|
+
# AFTER reactive_listnav so Enter prefers the highlighted option
|
|
690
|
+
# (listnavPick preventDefaults; tagsAdd then skips), and free text adds
|
|
691
|
+
# only when nothing is highlighted:
|
|
692
|
+
# input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
|
|
693
|
+
# Enter never submits the enclosing form — the client preventDefaults.
|
|
694
|
+
def reactive_tags_add
|
|
695
|
+
{ data: { action: "keydown.enter->reactive#tagsAdd" } }
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# A preloaded option row that ADDS its tag on click (issue #203) — the
|
|
699
|
+
# tags sibling of the combobox's on(:select) option, but CLIENT-ONLY
|
|
700
|
+
# (form state, no POST). Emits the [role=option] convention (so
|
|
701
|
+
# reactive_filter/reactive_listnav see it), the forced type="button" (a
|
|
702
|
+
# bare button inside a <form> would submit it), and the tag value the
|
|
703
|
+
# client reads. Compose extra attrs (the filter haystack, a testid) via
|
|
704
|
+
# mix. The tag can't contain a comma — it would corrupt the joined value.
|
|
705
|
+
def reactive_tags_option(tag)
|
|
706
|
+
{
|
|
707
|
+
type: "button",
|
|
708
|
+
role: "option",
|
|
709
|
+
data: {
|
|
710
|
+
action: "click->reactive#tagsPick",
|
|
711
|
+
reactive_tag_param: tags_tag!(:reactive_tags_option, tag)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# A chip's remove button (issue #203) — client-only, no POST. Two forms:
|
|
717
|
+
# inside the <template data-reactive-tags-template> chip, call it with NO
|
|
718
|
+
# tag (the client fills data-reactive-tag-param per cloned chip); on a
|
|
719
|
+
# server-rendered initial chip, pass the tag explicitly:
|
|
720
|
+
# button(**reactive_tags_remove(tag)) { "×" }
|
|
721
|
+
def reactive_tags_remove(tag = nil)
|
|
722
|
+
attrs = { type: "button", data: { action: "click->reactive#tagsRemove" } }
|
|
723
|
+
attrs[:data][:reactive_tag_param] = tags_tag!(:reactive_tags_remove, tag) unless tag.nil?
|
|
724
|
+
attrs
|
|
725
|
+
end
|
|
726
|
+
|
|
643
727
|
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
644
728
|
# parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
|
|
645
729
|
# is root-scoped by design (#15), so it can't express "this control
|
|
@@ -825,6 +909,21 @@ module Phlex
|
|
|
825
909
|
selector
|
|
826
910
|
end
|
|
827
911
|
|
|
912
|
+
# A declared tag value for reactive_tags_option/reactive_tags_remove
|
|
913
|
+
# (issue #203), validated non-blank and comma-free at render — the hidden
|
|
914
|
+
# field is comma-joined, so a declared tag containing a comma would
|
|
915
|
+
# corrupt the stored value (and a blank tag is a dead trigger).
|
|
916
|
+
def tags_tag!(helper, tag)
|
|
917
|
+
value = tag.to_s.strip
|
|
918
|
+
raise ArgumentError, "#{helper} needs a non-blank tag, got #{tag.inspect}" if value.empty?
|
|
919
|
+
if value.include?(",")
|
|
920
|
+
raise ArgumentError,
|
|
921
|
+
"#{helper}(#{tag.inspect}): a tag can't contain a comma — the hidden field is comma-joined"
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
value
|
|
925
|
+
end
|
|
926
|
+
|
|
828
927
|
# Issue #179: apply a confirm: gate to a trigger's data hash. A String is
|
|
829
928
|
# the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
|
|
830
929
|
# is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
|
|
@@ -63,21 +63,24 @@ module Phlex
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Assert (waiting) that the field with DOM id `id` has value `value`,
|
|
66
|
-
# RE-RESOLVING the field by its id on every poll
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
66
|
+
# RE-RESOLVING the field by its id on every poll and reading its live
|
|
67
|
+
# `.value` PROPERTY (issue #204) — NOT the value attribute. A
|
|
68
|
+
# reactive_compute reducer paints a computed output with `el.value = …`
|
|
69
|
+
# (the property); for a DISABLED / read-only output the value attribute
|
|
70
|
+
# never reflects that, so an attribute-based matcher reads "" and fails.
|
|
71
|
+
# Reading the property covers enabled AND disabled/computed fields — the
|
|
72
|
+
# exact case this matcher was built for — and keeps the morph-immunity
|
|
73
|
+
# (each poll re-finds the node by id, so a re-seed/morph that replaces the
|
|
74
|
+
# input can't surface a stale node or a transient blank).
|
|
71
75
|
#
|
|
72
76
|
# expect(page).to have_reactive_value("total", "6")
|
|
73
77
|
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# predicate — hence the PredicatePrefix disable, mirroring matchers.rb.
|
|
78
|
+
# `timeout:` (or `wait:`) overrides Capybara's default max wait. The
|
|
79
|
+
# `have_` prefix is Capybara-matcher convention (have_field/have_css), NOT
|
|
80
|
+
# a predicate — hence the PredicatePrefix disable, mirroring matchers.rb.
|
|
78
81
|
# rubocop:disable Naming/PredicatePrefix
|
|
79
|
-
def have_reactive_value(id, value,
|
|
80
|
-
|
|
82
|
+
def have_reactive_value(id, value, timeout: nil, wait: nil)
|
|
83
|
+
ReactiveValueMatcher.new(id, value, wait: timeout || wait)
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
# Assert (waiting) that the node with DOM id `id` has TEXT `value`,
|
|
@@ -91,6 +94,91 @@ module Phlex
|
|
|
91
94
|
end
|
|
92
95
|
# rubocop:enable Naming/PredicatePrefix
|
|
93
96
|
|
|
97
|
+
# A waiting matcher that asserts a field's live `.value` PROPERTY (issue
|
|
98
|
+
# #204), read by id via evaluate_script so it sees a value a reducer set
|
|
99
|
+
# on a DISABLED output (where the value attribute stays blank). Polls until
|
|
100
|
+
# the property equals the expected value or the wait budget elapses,
|
|
101
|
+
# re-resolving the node by id every cycle (morph-immune). Supports negation
|
|
102
|
+
# (`not_to`) the Capybara way: the negative holds as soon as the property
|
|
103
|
+
# differs from the expected value (an absent field reads nil, which never
|
|
104
|
+
# equals a String expectation — so a missing field satisfies `not_to`).
|
|
105
|
+
#
|
|
106
|
+
# A plain class (not RSpec::Matchers.define) so it needs no RSpec at load
|
|
107
|
+
# time beyond the duck-typed matcher protocol RSpec calls (matches?,
|
|
108
|
+
# does_not_match?, failure_message*), keeping System loadable under Capybara
|
|
109
|
+
# alone. The expected value is stringified because a DOM `.value` is always
|
|
110
|
+
# a JS string on the wire (a numeric literal like 6 compares as "6").
|
|
111
|
+
class ReactiveValueMatcher
|
|
112
|
+
def initialize(id, value, wait: nil)
|
|
113
|
+
@id = id
|
|
114
|
+
@expected = value.to_s
|
|
115
|
+
@wait = wait
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def matches?(page)
|
|
119
|
+
@page = page
|
|
120
|
+
poll_until { current_value == @expected }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# does_not_match? is RSpec's REQUIRED negated-matcher protocol method — its
|
|
124
|
+
# name is fixed by RSpec, not a predicate we get to rename.
|
|
125
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
126
|
+
def does_not_match?(page)
|
|
127
|
+
@page = page
|
|
128
|
+
# The negative is satisfied the moment the property is NOT the expected
|
|
129
|
+
# value (or the field is absent → nil). poll_until returns true as soon
|
|
130
|
+
# as that holds, false if it stayed equal for the whole budget.
|
|
131
|
+
poll_until { current_value != @expected }
|
|
132
|
+
end
|
|
133
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
134
|
+
|
|
135
|
+
def failure_message
|
|
136
|
+
"expected ##{@id} to have value #{@expected.inspect} (its .value property), " \
|
|
137
|
+
"but after waiting it was #{current_value.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def failure_message_when_negated
|
|
141
|
+
"expected ##{@id} NOT to have value #{@expected.inspect} (its .value property), but it did"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# The field's live `.value` property, re-resolved each call. The
|
|
147
|
+
# identifier is matched by DOM id OR name (mirroring Capybara's
|
|
148
|
+
# have_field, which matches id/name/label) — reactive_field emits a
|
|
149
|
+
# `name`, not an `id`, so an id-only lookup would find nothing. Returns
|
|
150
|
+
# the String value, or nil when the field is absent (Playwright serializes
|
|
151
|
+
# a JS null as an empty Hash, so a non-String result is normalized to nil
|
|
152
|
+
# — the matcher then treats it as "not settled yet" and keeps polling
|
|
153
|
+
# rather than comparing a Hash to the expected String).
|
|
154
|
+
def current_value
|
|
155
|
+
raw = @page.evaluate_script(<<~JS)
|
|
156
|
+
(() => {
|
|
157
|
+
const k = #{@id.to_json}
|
|
158
|
+
const el = document.getElementById(k) || document.getElementsByName(k)[0]
|
|
159
|
+
return el ? el.value : null
|
|
160
|
+
})()
|
|
161
|
+
JS
|
|
162
|
+
raw.is_a?(::String) ? raw : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Bounded poll on a JS-property condition Capybara can't express as a
|
|
166
|
+
# built-in waiting matcher. Uses the monotonic clock and the configured
|
|
167
|
+
# (or overridden) Capybara max wait — the same shape the system specs'
|
|
168
|
+
# hand-rolled wait_for used, now lifted into the helper.
|
|
169
|
+
def poll_until
|
|
170
|
+
deadline = now + (@wait || ::Capybara.default_max_wait_time)
|
|
171
|
+
loop do
|
|
172
|
+
return true if yield
|
|
173
|
+
return false if now >= deadline
|
|
174
|
+
|
|
175
|
+
sleep 0.05
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
180
|
+
end
|
|
181
|
+
|
|
94
182
|
private
|
|
95
183
|
|
|
96
184
|
# Fold an explicit `timeout:` into Capybara's `wait:` option, or omit it so
|