phlex-reactive 0.16.2 → 0.16.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 +83 -1
- data/README.md +62 -14
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/early.js +8 -2
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +37 -7
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/drag.js +34 -12
- data/app/javascript/phlex/reactive/features/drag.min.js +2 -2
- data/app/javascript/phlex/reactive/features/drag.min.js.map +3 -3
- data/app/javascript/phlex/reactive/inspect.js +5 -2
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +5 -5
- data/app/javascript/phlex/reactive/runtime.js +30 -4
- data/lib/phlex/reactive/component/drag.rb +26 -4
- data/lib/phlex/reactive/component/helpers.rb +37 -3
- data/lib/phlex/reactive/component/identity.rb +1 -1
- data/lib/phlex/reactive/shortcut.rb +25 -13
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -1751,9 +1751,28 @@ function keyFilterMatches(filter, event, keyMappings) {
|
|
|
1751
1751
|
// its bare keydown hears every key, and this check alone picks the chord.
|
|
1752
1752
|
// (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
|
|
1753
1753
|
const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
|
|
1754
|
+
// Issue #429: the keydowns a shortcut prevented itself. A key another handler
|
|
1755
|
+
// already claimed (the drag feature's Escape cancelling a drag, an
|
|
1756
|
+
// element-bound keydown binding) is not the shortcut's — unless it says so,
|
|
1757
|
+
// `handled: true` — but a shortcut's OWN preventDefault does not count: two
|
|
1758
|
+
// shortcuts on one chord both run. Nor does early.js's, made for a shortcut
|
|
1759
|
+
// whose root had not connected: it adds the event to this same set (shared
|
|
1760
|
+
// through the early state), and its replay reads as not prevented. (So while
|
|
1761
|
+
// such a root is on the page, early.js's capture-phase preventDefault lands
|
|
1762
|
+
// before a drag's, and that Escape still runs the shortcuts on its chord — as
|
|
1763
|
+
// before #429: a preventDefault cannot tell who came second.)
|
|
1764
|
+
const shortcutPrevented = () => (earlyState().prevented ??= new WeakSet())
|
|
1765
|
+
// A trigger's preventDefault, a shortcut's remembered as its own (only when
|
|
1766
|
+
// nothing prevented the key first: handled: true on a handled key does not
|
|
1767
|
+
// make it the next shortcut's).
|
|
1768
|
+
function preventTrigger(event, shortcut) {
|
|
1769
|
+
if (shortcut != null && !event.defaultPrevented) shortcutPrevented().add(event)
|
|
1770
|
+
event.preventDefault()
|
|
1771
|
+
}
|
|
1754
1772
|
// A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
|
|
1755
1773
|
// keydown through and this check, per record, picks the chord that fired.
|
|
1756
1774
|
function shortcutApplies(spec, event, keyMappings) {
|
|
1775
|
+
if (event.defaultPrevented && spec?.handled !== true && !shortcutPrevented().has(event)) return false
|
|
1757
1776
|
const target = event.target
|
|
1758
1777
|
if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
|
|
1759
1778
|
return false
|
|
@@ -2474,7 +2493,9 @@ function earlyReplayEvent(event, el, win) {
|
|
|
2474
2493
|
submitter: event.submitter,
|
|
2475
2494
|
bubbles: event.bubbles,
|
|
2476
2495
|
timeStamp: event.timeStamp,
|
|
2477
|
-
|
|
2496
|
+
// Not when early.js prevented it for a shortcut (issue #429): a shortcut
|
|
2497
|
+
// skips only a key some OTHER handler prevented first.
|
|
2498
|
+
defaultPrevented: event.defaultPrevented && !shortcutPrevented().has(event),
|
|
2478
2499
|
preventDefault() {},
|
|
2479
2500
|
stopPropagation() {},
|
|
2480
2501
|
stopImmediatePropagation() {},
|
|
@@ -3255,7 +3276,8 @@ export default class extends Controller {
|
|
|
3255
3276
|
// A shortcut (issue #357) IS prevented: its key filter has already narrowed
|
|
3256
3277
|
// the window binding to one chord the page claims (mod+s must not also open
|
|
3257
3278
|
// the browser's save dialog).
|
|
3258
|
-
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
|
|
3279
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
|
|
3280
|
+
preventTrigger(event, shortcut)
|
|
3259
3281
|
|
|
3260
3282
|
// A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
|
|
3261
3283
|
// materialize entry point, which dedupes it against the observer, the
|
|
@@ -3374,8 +3396,12 @@ export default class extends Controller {
|
|
|
3374
3396
|
// Held past this event's dispatch: cancel its native default NOW, by
|
|
3375
3397
|
// dispatch()'s own rule, or a submit/link would act before the request.
|
|
3376
3398
|
const { shortcut, window: windowBound, optimistic } = event.params
|
|
3399
|
+
// A shortcut that does not apply (the other twin, a field, a key handled
|
|
3400
|
+
// first) is neither prevented nor held, as dispatch() would leave it.
|
|
3401
|
+
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
3377
3402
|
const target = event.currentTarget ?? event.target
|
|
3378
|
-
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
|
|
3403
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
|
|
3404
|
+
preventTrigger(event, shortcut)
|
|
3379
3405
|
heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
|
|
3380
3406
|
const data = root.dataset
|
|
3381
3407
|
if (data.reactiveDormant) {
|
|
@@ -3463,7 +3489,7 @@ export default class extends Controller {
|
|
|
3463
3489
|
// (possibly async) confirm gate below — a native default can't wait for a
|
|
3464
3490
|
// pending dialog (same ordering as dispatch()).
|
|
3465
3491
|
// A shortcut (issue #357) is prevented: it is one chord the page claims.
|
|
3466
|
-
if (shortcut != null || !windowBound) event
|
|
3492
|
+
if (shortcut != null || !windowBound) preventTrigger(event, shortcut)
|
|
3467
3493
|
|
|
3468
3494
|
// Resolve the effective confirm message — static string, or the conditional
|
|
3469
3495
|
// Hash form (issue #179) evaluated over collected fields (a promise of it
|
|
@@ -9,7 +9,8 @@ module Phlex
|
|
|
9
9
|
# Draggable regions (issue #416): a box that moves and resizes inside a
|
|
10
10
|
# frame, or a point that moves inside it, with the result in fields as
|
|
11
11
|
# fractions of the frame — 0..1 on each axis, so a resize, a responsive
|
|
12
|
-
# layout or a morph changes nothing about them.
|
|
12
|
+
# layout or a morph changes nothing about them. With scale: 100 the
|
|
13
|
+
# fields hold percentages instead (issue #428).
|
|
13
14
|
#
|
|
14
15
|
# div(**mix(reactive_frame, class: "relative")) do
|
|
15
16
|
# img(src: @photo.url, alt: "", class: "block w-full")
|
|
@@ -67,14 +68,17 @@ module Phlex
|
|
|
67
68
|
# aspect: a Numeric ratio (16/9r), or the name of a field holding one
|
|
68
69
|
# ("16:9", "1.5", or "" for free), read at drag start
|
|
69
70
|
# min: the smallest width/height, a fraction of the frame
|
|
71
|
+
# scale: what the fields hold: the fraction times this (100: percent,
|
|
72
|
+
# 2 decimals); value:, the style and the action's params stay fractions
|
|
70
73
|
# action: sent once on release, with x/y/width/height params
|
|
71
74
|
# busy: on(busy:)'s vocabulary, applied while that action is pending
|
|
72
75
|
# messages: the live-region words, merged over DRAG_MESSAGES
|
|
73
|
-
def reactive_box(fields:, value:, aspect: nil, min: 0.05, action: nil, busy: nil, messages: nil)
|
|
76
|
+
def reactive_box(fields:, value:, aspect: nil, min: 0.05, scale: 1, action: nil, busy: nil, messages: nil)
|
|
74
77
|
fields = drag_fields!(:reactive_box, fields, BOX_KEYS)
|
|
75
78
|
value = drag_value!(:reactive_box, value, BOX_KEYS)
|
|
76
79
|
drag_inside_frame!(value)
|
|
77
80
|
data = { reactive_box: fields.to_json, reactive_box_min: drag_min!(min) }
|
|
81
|
+
drag_scale!(:reactive_box, scale, data)
|
|
78
82
|
drag_aspect!(aspect, data) unless aspect.nil?
|
|
79
83
|
drag_action!(:reactive_box, action, busy, data)
|
|
80
84
|
data[:reactive_box_messages] = drag_messages!(:reactive_box, messages)
|
|
@@ -99,12 +103,14 @@ module Phlex
|
|
|
99
103
|
|
|
100
104
|
# A point (its centre) that moves inside the frame — or, with within:,
|
|
101
105
|
# inside the element it sits in that matches the selector (a box): its
|
|
102
|
-
# fractions are then of that element, and it moves with it.
|
|
103
|
-
|
|
106
|
+
# fractions are then of that element, and it moves with it. scale: is
|
|
107
|
+
# reactive_box's.
|
|
108
|
+
def reactive_point(fields:, value:, within: nil, scale: 1, action: nil, busy: nil, messages: nil)
|
|
104
109
|
fields = drag_fields!(:reactive_point, fields, POINT_KEYS)
|
|
105
110
|
value = drag_value!(:reactive_point, value, POINT_KEYS)
|
|
106
111
|
data = { reactive_point: fields.to_json }
|
|
107
112
|
data[:reactive_point_within] = drag_within!(within) unless within.nil?
|
|
113
|
+
drag_scale!(:reactive_point, scale, data)
|
|
108
114
|
drag_action!(:reactive_point, action, busy, data)
|
|
109
115
|
data[:reactive_point_messages] = drag_messages!(:reactive_point, messages)
|
|
110
116
|
style = "position: absolute; left: #{drag_percent(value[:x])}; top: #{drag_percent(value[:y])}; " \
|
|
@@ -180,6 +186,22 @@ module Phlex
|
|
|
180
186
|
"reactive_box min: must be a fraction of the frame above 0 and at most 1 (e.g. 0.05), got #{min.inspect}"
|
|
181
187
|
end
|
|
182
188
|
|
|
189
|
+
# Issue #428: the fields' scale, written only when it is not 1, so a
|
|
190
|
+
# region without scale: renders as it always did. The browser reads it
|
|
191
|
+
# as a Float, so one that is Infinity (10**400) or 0 (1/10**400) as a
|
|
192
|
+
# Float is refused, like a Float that is.
|
|
193
|
+
def drag_scale!(helper, scale, data)
|
|
194
|
+
float = scale.to_f if scale.is_a?(Numeric) && scale.real?
|
|
195
|
+
unless float&.finite? && float.positive?
|
|
196
|
+
raise ArgumentError,
|
|
197
|
+
"#{helper} scale: must be a positive finite Numeric (100: percent), got #{scale.inspect}"
|
|
198
|
+
end
|
|
199
|
+
return if scale == 1
|
|
200
|
+
|
|
201
|
+
# (A Rational would render as "1000/3", which the browser cannot read.)
|
|
202
|
+
data[:"#{helper}_scale"] = scale.is_a?(Integer) || scale.is_a?(Float) ? scale : float
|
|
203
|
+
end
|
|
204
|
+
|
|
183
205
|
def drag_aspect!(aspect, data)
|
|
184
206
|
if aspect.is_a?(Numeric) && aspect.real? && aspect.positive? && aspect.finite?
|
|
185
207
|
data[:reactive_box_ratio] = aspect.to_f
|
|
@@ -313,10 +313,13 @@ module Phlex
|
|
|
313
313
|
# runs the one for its platform). Unlike a plain window binding, the
|
|
314
314
|
# client prevents the chord's native default, and it skips the chord
|
|
315
315
|
# while focus is in an input, textarea, select or contenteditable
|
|
316
|
-
# (native text undo keeps working) unless the Hash form opts in.
|
|
317
|
-
#
|
|
316
|
+
# (native text undo keeps working) unless the Hash form opts in. A
|
|
317
|
+
# keydown another handler already prevented (the drag's Escape
|
|
318
|
+
# cancelling a drag) is skipped too, unless `handled: true` (issue
|
|
319
|
+
# #429). It decides event:, window: and outside:, so passing one raises.
|
|
318
320
|
# span(**on(:undo, shortcut: "mod+z"))
|
|
319
321
|
# span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
|
|
322
|
+
# span(**on(:leave, shortcut: { keys: "esc", handled: true }))
|
|
320
323
|
#
|
|
321
324
|
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
322
325
|
# COSMETIC hints the client applies the instant the trigger fires and
|
|
@@ -741,7 +744,18 @@ module Phlex
|
|
|
741
744
|
# exactly one of field/input: per call — the field form stays the
|
|
742
745
|
# blessed default:
|
|
743
746
|
# reactive_filter(input: "#user_tags_query")
|
|
744
|
-
|
|
747
|
+
#
|
|
748
|
+
# Issue #425: `match:` picks where the query must sit in the haystack —
|
|
749
|
+
# :substring (anywhere, the default), :prefix (at its start) or
|
|
750
|
+
# :word_start (at its start, or right after whitespace or one of
|
|
751
|
+
# - ' ( ) , . /). A long alphabetical list (countries, cities, people)
|
|
752
|
+
# wants :word_start, so "n" keeps Nantes and New York, not every name
|
|
753
|
+
# with an n in it. `fold: true` folds diacritics on BOTH sides before
|
|
754
|
+
# comparing, so "zur" finds Zürich and "zür" still does. The defaults
|
|
755
|
+
# emit nothing, so an existing filter's wire is byte-identical:
|
|
756
|
+
# reactive_filter(:q, match: :word_start, fold: true)
|
|
757
|
+
def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil,
|
|
758
|
+
match: :substring, fold: false)
|
|
745
759
|
# nil-presence, NOT truthiness: `input: cond && "#sel"` with cond false
|
|
746
760
|
# must fail loudly in filter_selector! below (a boolean is never a
|
|
747
761
|
# selector), never slip into the field branch and emit a dead binding.
|
|
@@ -768,6 +782,10 @@ module Phlex
|
|
|
768
782
|
# byte-stable wire and always-emit an attribute the client would then query).
|
|
769
783
|
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
770
784
|
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
785
|
+
# match:/fold: too (issue #425): the defaults emit nothing.
|
|
786
|
+
mode = filter_match!(match)
|
|
787
|
+
data[:reactive_filter_match] = mode.to_s unless mode == :substring
|
|
788
|
+
data[:reactive_filter_fold] = "true" if fold
|
|
771
789
|
|
|
772
790
|
{ data: }
|
|
773
791
|
end
|
|
@@ -1427,8 +1445,24 @@ module Phlex
|
|
|
1427
1445
|
LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
|
|
1428
1446
|
LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
1429
1447
|
|
|
1448
|
+
# reactive_filter's match: modes (issue #425), in the order the error names them.
|
|
1449
|
+
FILTER_MATCH_MODES = %i[substring prefix word_start].freeze
|
|
1450
|
+
private_constant :FILTER_MATCH_MODES
|
|
1451
|
+
|
|
1430
1452
|
private
|
|
1431
1453
|
|
|
1454
|
+
# reactive_filter's match: (issue #425), a Symbol or a String naming one
|
|
1455
|
+
# of FILTER_MATCH_MODES (the reactive_persist storage: precedent).
|
|
1456
|
+
# Anything else raises at render: the client would fall back to
|
|
1457
|
+
# substring matching and the list would narrow the wrong way.
|
|
1458
|
+
def filter_match!(match)
|
|
1459
|
+
mode = match.to_sym if match.is_a?(Symbol) || match.is_a?(String)
|
|
1460
|
+
return mode if FILTER_MATCH_MODES.include?(mode)
|
|
1461
|
+
|
|
1462
|
+
raise ArgumentError,
|
|
1463
|
+
"reactive_filter match: must be one of #{FILTER_MATCH_MODES.join(", ")}, got #{match.inspect}"
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1432
1466
|
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
1433
1467
|
# stringified (issue #163). A blank selector is a dead binding — the
|
|
1434
1468
|
# client would silently match nothing — so it fails loudly at render,
|
|
@@ -10,7 +10,7 @@ module Phlex
|
|
|
10
10
|
#
|
|
11
11
|
# THE token hot path. Both memos below are bare defined?/||= fast paths
|
|
12
12
|
# ON PURPOSE: a render must never pay a per-read generation compare
|
|
13
|
-
# (issue #115 invariant, .
|
|
13
|
+
# (issue #115 invariant, AGENTS.md → Performance). Coherence comes
|
|
14
14
|
# from the WRITE side instead — Component::Registry.bump! sweeps these
|
|
15
15
|
# memos off the declaring class and all its descendants whenever any
|
|
16
16
|
# registry declaration lands (class-load-shaped, rare), so they can't go
|
|
@@ -7,13 +7,16 @@ module Phlex
|
|
|
7
7
|
#
|
|
8
8
|
# Shortcut.parse("mod+z") # String form
|
|
9
9
|
# Shortcut.parse({ keys: "esc", in_editable: true }) # Hash form
|
|
10
|
+
# Shortcut.parse({ keys: "esc", handled: true }) # runs on a handled key
|
|
10
11
|
#
|
|
11
12
|
# `mod` is meta on Apple platforms and ctrl elsewhere. Stimulus has no
|
|
12
13
|
# "either" modifier (its key filter compares all four modifier flags
|
|
13
14
|
# exactly), so a `mod` chord renders TWO descriptors, a meta twin and a ctrl
|
|
14
15
|
# twin, and the client runs only the twin for its platform. Stimulus keeps
|
|
15
16
|
# the key filtering; the client adds the platform check, the editable-target
|
|
16
|
-
# guard
|
|
17
|
+
# guard, the handled-key skip (issue #429: a keydown another handler already
|
|
18
|
+
# prevented is not the shortcut's, unless `handled: true`) and
|
|
19
|
+
# preventDefault. The exception is a key Stimulus's default map
|
|
17
20
|
# lacks (delete, backspace — issue #366): it binds a bare window keydown,
|
|
18
21
|
# no twins, and the client matches the whole chord.
|
|
19
22
|
#
|
|
@@ -28,7 +31,7 @@ module Phlex
|
|
|
28
31
|
# `keydown@window` and the client alone matches it.
|
|
29
32
|
LIBRARY_KEYS = %w[delete backspace].freeze
|
|
30
33
|
NAMED_KEYS = (STIMULUS_KEYS + LIBRARY_KEYS).freeze
|
|
31
|
-
HASH_KEYS = %i[keys in_editable].freeze
|
|
34
|
+
HASH_KEYS = %i[keys in_editable handled].freeze
|
|
32
35
|
|
|
33
36
|
private_constant :MODIFIERS, :STIMULUS_KEYS, :LIBRARY_KEYS, :NAMED_KEYS, :HASH_KEYS
|
|
34
37
|
|
|
@@ -36,34 +39,38 @@ module Phlex
|
|
|
36
39
|
|
|
37
40
|
def self.parse(spec)
|
|
38
41
|
case spec
|
|
39
|
-
when String then new(spec, in_editable: false)
|
|
42
|
+
when String then new(spec, in_editable: false, handled: false)
|
|
40
43
|
when Hash then from_hash(spec)
|
|
41
44
|
else
|
|
42
45
|
raise ArgumentError,
|
|
43
|
-
"shortcut: takes a String (\"mod+z\") or a Hash ({ keys: \"esc\", in_editable: true
|
|
44
|
-
"got #{spec.inspect}"
|
|
46
|
+
"shortcut: takes a String (\"mod+z\") or a Hash ({ keys: \"esc\", in_editable: true, " \
|
|
47
|
+
"handled: true }), got #{spec.inspect}"
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def self.from_hash(spec)
|
|
49
52
|
keys = spec[:keys]
|
|
50
53
|
in_editable = spec.fetch(:in_editable, false)
|
|
54
|
+
handled = spec.fetch(:handled, false)
|
|
51
55
|
unless (spec.keys - HASH_KEYS).empty? && keys.is_a?(String)
|
|
52
56
|
raise ArgumentError,
|
|
53
|
-
"shortcut: as a Hash takes keys: (a String, e.g. \"esc\") and
|
|
54
|
-
"in_editable: (true/false), got #{spec.inspect}"
|
|
57
|
+
"shortcut: as a Hash takes keys: (a String, e.g. \"esc\") and optional " \
|
|
58
|
+
"in_editable: and handled: (true/false), got #{spec.inspect}"
|
|
55
59
|
end
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
{ in_editable:, handled: }.each do |name, value|
|
|
61
|
+
next if [true, false].include?(value)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "shortcut: #{name}: is true or false, got #{value.inspect}"
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
new(keys, in_editable:)
|
|
66
|
+
new(keys, in_editable:, handled:)
|
|
61
67
|
end
|
|
62
68
|
private_class_method :from_hash
|
|
63
69
|
|
|
64
|
-
def initialize(keys, in_editable:)
|
|
70
|
+
def initialize(keys, in_editable:, handled:)
|
|
65
71
|
tokens = keys.downcase.split("+").map(&:strip)
|
|
66
72
|
@in_editable = in_editable
|
|
73
|
+
@handled = handled
|
|
67
74
|
@keys = canonical(keys, tokens).freeze
|
|
68
75
|
freeze
|
|
69
76
|
end
|
|
@@ -83,8 +90,13 @@ module Phlex
|
|
|
83
90
|
|
|
84
91
|
# The wire form the client reads (data-reactive-shortcut-param, or the
|
|
85
92
|
# on_client record's "shortcut"): the canonical String, or the Hash form
|
|
86
|
-
# when the shortcut also fires inside editable targets
|
|
87
|
-
|
|
93
|
+
# when the shortcut also fires inside editable targets and/or on a key
|
|
94
|
+
# another handler already prevented (issue #429).
|
|
95
|
+
def param
|
|
96
|
+
return @keys unless @in_editable || @handled
|
|
97
|
+
|
|
98
|
+
{ "keys" => @keys, "inEditable" => (true if @in_editable), "handled" => (true if @handled) }.compact
|
|
99
|
+
end
|
|
88
100
|
|
|
89
101
|
private
|
|
90
102
|
|