ruflet_rails 0.0.13 → 0.0.14

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.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ class NativeApp
6
+ private
7
+
8
+ # --- Declared actions --------------------------------------------------
9
+
10
+ # Route a `data-ruflet-action` element to native.
11
+ def dispatch_action(spec)
12
+ component = (spec["component"] || spec["type"]).to_s
13
+ action = spec["action"].to_s
14
+
15
+ case component
16
+ when "navigation", "navigate"
17
+ navigate_screen(spec["url"], action, spec)
18
+ when "request", "form"
19
+ submit_webview_request(spec["url"], spec["method"] || action || "post")
20
+ when "dialog"
21
+ show_native_dialog(spec)
22
+ when "toast"
23
+ show_toast(spec)
24
+ when "sheet"
25
+ present_sheet(spec)
26
+ when "menu"
27
+ show_native_menu(spec)
28
+ when "drawer"
29
+ show_drawer
30
+ when "share"
31
+ share_content(spec)
32
+ when "clipboard", "copy"
33
+ copy_to_clipboard(spec)
34
+ when "launcher", "url", "url_launcher"
35
+ launch_external_url(spec)
36
+ when "haptic", "haptic_feedback"
37
+ haptic_feedback(spec)
38
+ else
39
+ case action
40
+ when "dialog" then show_native_dialog(spec)
41
+ when "toast" then show_toast(spec)
42
+ when "sheet" then present_sheet(spec)
43
+ when "menu" then show_native_menu(spec)
44
+ when "drawer" then show_drawer
45
+ when "end_drawer" then show_end_drawer
46
+ when "share" then share_content(spec)
47
+ when "copy", "clipboard" then copy_to_clipboard(spec)
48
+ when "launch", "url", "url_launcher" then launch_external_url(spec)
49
+ when "haptic", "haptic_feedback" then haptic_feedback(spec)
50
+ when "back" then pop
51
+ when "delete", "post", "patch", "put" then submit_webview_request(spec["url"], action)
52
+ when "navigate" then navigate_screen(spec["url"], spec["mode"] || "push", spec)
53
+ when "push", "replace", "root" then navigate_screen(spec["url"], action, spec)
54
+ end
55
+ end
56
+ end
57
+
58
+ # --- Native services ---------------------------------------------------
59
+
60
+ def share_content(spec)
61
+ clear_transient_overlays
62
+ files = spec["files"]
63
+ uri = share_uri_for(spec)
64
+ text = spec["text"] || spec["message"] || spec["content"]
65
+
66
+ if files
67
+ @page.share_files(files, text: text, title: spec["title"], subject: spec["subject"])
68
+ elsif uri && text.to_s.empty?
69
+ @page.share_uri(uri)
70
+ else
71
+ @page.share_text(text.to_s, title: spec["title"], subject: spec["subject"])
72
+ end
73
+ haptic_feedback({ "style" => "selection" }) if spec["haptic"]
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ def copy_to_clipboard(spec)
79
+ clear_transient_overlays
80
+ value = spec["text"] || spec["value"] || spec["content"] || spec["message"] || spec["url"]
81
+ return if value.to_s.empty?
82
+
83
+ @page.set_clipboard(value)
84
+ show_toast(
85
+ "message" => spec["toast"].to_s,
86
+ "duration" => (spec["toast_duration"] || spec["duration"] || 900).to_s
87
+ ) unless spec["toast"].to_s.empty?
88
+ haptic_feedback({ "style" => "selection" }) if spec.fetch("haptic", true)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ def share_uri_for(spec)
94
+ uri = spec["uri"] || spec["url"]
95
+ return current_url if uri.to_s.empty? || uri.to_s == "#"
96
+
97
+ uri
98
+ end
99
+
100
+ def same_url?(left, right)
101
+ absolute_url(left).to_s == absolute_url(right).to_s
102
+ end
103
+
104
+ def clear_transient_overlays
105
+ @page.snackbar = nil if @page.respond_to?(:snackbar=)
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ def launch_external_url(spec)
111
+ url = spec["url"] || spec["href"] || spec["uri"]
112
+ return if url.to_s.empty?
113
+
114
+ @page.launch_url(url.to_s, mode: spec["mode"])
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ def submit_webview_request(url, method = "post")
120
+ target = absolute_url(url)
121
+ return if target.empty?
122
+
123
+ verb = method.to_s.downcase
124
+ verb = "post" if verb.empty?
125
+ js = <<~JS
126
+ (function () {
127
+ var token = document.querySelector("meta[name='csrf-token']");
128
+ var form = document.createElement("form");
129
+ form.method = "post";
130
+ form.action = #{target.to_json};
131
+ form.style.display = "none";
132
+ var methodInput = document.createElement("input");
133
+ methodInput.type = "hidden";
134
+ methodInput.name = "_method";
135
+ methodInput.value = #{verb.to_json};
136
+ form.appendChild(methodInput);
137
+ if (token && token.content) {
138
+ var csrf = document.createElement("input");
139
+ csrf.type = "hidden";
140
+ csrf.name = "authenticity_token";
141
+ csrf.value = token.content;
142
+ form.appendChild(csrf);
143
+ }
144
+ document.body.appendChild(form);
145
+ form.submit();
146
+ })();
147
+ JS
148
+ @screens.last&.webview&.run_javascript(js)
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ def haptic_feedback(spec)
154
+ style = (spec["style"] || spec["kind"] || spec["impact"] || "selection").to_s
155
+ case style
156
+ when "heavy" then @page.heavy_impact
157
+ when "medium" then @page.medium_impact
158
+ when "light" then @page.light_impact
159
+ when "vibrate" then @page.vibrate
160
+ else @page.selection_click
161
+ end
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ class NativeApp
6
+ CONTROL_PROP_KEYS = {
7
+ appbar: %w[
8
+ actions_padding adaptive automatically_imply_leading bgcolor center_title clip_behavior color data elevation
9
+ elevation_on_scroll exclude_header_semantics force_material_transparency leading_width opacity secondary
10
+ shadow_color shape title_spacing title_text_style toolbar_height toolbar_opacity toolbar_text_style tooltip visible
11
+ ],
12
+ navigation_bar: %w[
13
+ adaptive bgcolor border elevation height indicator_color indicator_shape label_behavior label_padding margin
14
+ overlay_color shadow_color surface_tint_color tooltip visible width
15
+ ],
16
+ navigation_bar_destination: %w[
17
+ adaptive bgcolor data disabled selected_icon tooltip visible
18
+ ],
19
+ navigation_drawer: %w[
20
+ adaptive bgcolor elevation indicator_color indicator_shape opacity shadow_color surface_tint_color tile_padding
21
+ tooltip visible width
22
+ ],
23
+ list_tile: %w[
24
+ adaptive autofocus bgcolor content_padding dense disabled enable_feedback height horizontal_spacing hover_color
25
+ icon_color is_three_line min_height min_leading_width min_vertical_padding selected_color selected_tile_color
26
+ shape splash_color style subtitle_text_style text_color title_alignment title_text_style tooltip visible
27
+ visual_density
28
+ ],
29
+ navigation_rail: %w[
30
+ bgcolor elevation extended group_alignment height indicator_color indicator_shape label_type min_extended_width
31
+ min_width opacity selected_label_text_style tooltip trailing unselected_label_text_style use_indicator visible width
32
+ ],
33
+ navigation_rail_destination: %w[
34
+ indicator_color indicator_shape padding selected_icon tooltip visible
35
+ ],
36
+ icon_button: %w[
37
+ adaptive alignment autofocus bgcolor disabled disabled_color enable_feedback focus_color height highlight_color
38
+ hover_color icon_color icon_size padding selected selected_icon selected_icon_color size_constraints splash_color
39
+ splash_radius style tooltip visible visual_density width
40
+ ],
41
+ icon: %w[color size opacity tooltip visible],
42
+ text: %w[
43
+ color font_family italic max_lines no_wrap opacity selectable semantics_label size style text_align
44
+ theme_style tooltip visible weight
45
+ ],
46
+ alert_dialog: %w[
47
+ action_button_padding actions_alignment actions_overflow_button_spacing actions_padding adaptive alignment
48
+ barrier_color bgcolor clip_behavior content_padding content_text_style elevation icon_color icon_padding
49
+ inset_padding modal opacity open scrollable semantics_label shadow_color shape title_padding title_text_style
50
+ tooltip visible
51
+ ],
52
+ snack_bar: %w[
53
+ action_overflow_threshold adaptive behavior bgcolor clip_behavior close_icon_color dismiss_direction duration
54
+ elevation margin opacity open padding persist shape show_close_icon tooltip visible width
55
+ ],
56
+ bottom_sheet: %w[
57
+ adaptive animation_style barrier_color bgcolor clip_behavior dismissible draggable elevation fullscreen
58
+ maintain_bottom_view_insets_padding opacity open scrollable shape show_drag_handle size_constraints tooltip
59
+ use_safe_area visible
60
+ ],
61
+ container: %w[
62
+ alignment bgcolor border border_radius bottom clip_behavior color expand height left margin opacity padding
63
+ right shadow tooltip top visible width
64
+ ],
65
+ shimmer: %w[base_color direction duration highlight_color loop period visible]
66
+ }.freeze
67
+
68
+ INTERNAL_PROP_KEYS = %w[
69
+ action actions component confirm content files haptic href icon items label leading loading message mode nav
70
+ payload selected subject text title toast toast_duration type uri url value
71
+ ].freeze
72
+
73
+ private
74
+
75
+ def ruflet_props_for(control, source, except: [])
76
+ return {} unless source.is_a?(Hash)
77
+
78
+ raw = {}
79
+ raw.merge!(stringify_hash(source["props"])) if source["props"].is_a?(Hash)
80
+ raw.merge!(stringify_hash(source))
81
+ rejected = INTERNAL_PROP_KEYS + Array(except).map(&:to_s)
82
+ allowed = CONTROL_PROP_KEYS.fetch(control).map(&:to_s)
83
+ raw.each_with_object({}) do |(key, value), props|
84
+ next if value.nil?
85
+ next if rejected.include?(key.to_s)
86
+ next unless allowed.include?(key.to_s)
87
+
88
+ props[key.to_sym] = ruflet_stringify_nested(value)
89
+ end
90
+ end
91
+
92
+ def nested_ruflet_props(source, *keys, control:)
93
+ return {} unless source.is_a?(Hash)
94
+
95
+ keys.each do |key|
96
+ value = source[key.to_s]
97
+ return ruflet_props_for(control, value) if value.is_a?(Hash)
98
+ end
99
+ {}
100
+ end
101
+
102
+ def stringify_hash(value)
103
+ value.each_with_object({}) { |(key, nested), out| out[key.to_s] = nested }
104
+ end
105
+
106
+ def ruflet_stringify_nested(value)
107
+ case value
108
+ when Hash
109
+ value.each_with_object({}) { |(key, nested), out| out[key.to_s] = ruflet_stringify_nested(nested) }
110
+ when Array
111
+ value.map { |nested| ruflet_stringify_nested(nested) }
112
+ else
113
+ value
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ class NativeApp
6
+ # Tiny WebView-side adapter: read ERB-rendered `data-ruflet-*`
7
+ # declarations and report them so Ruby can build normal Ruflet controls.
8
+ HTML_ADAPTER_JS = <<~JS
9
+ (function () {
10
+ var rufletInsideSheet = __RUFLET_INSIDE_SHEET__;
11
+
12
+ function report(kind, value) { console.log("ruflet:" + kind + ":" + value); }
13
+ function attr(el, name) {
14
+ var v = el.getAttribute(name);
15
+ if (v != null) return v;
16
+ v = el.getAttribute("data-" + name);
17
+ if (v != null) return v;
18
+ return el.getAttribute("data-ruflet-" + name);
19
+ }
20
+ function has(el, name) {
21
+ return el.hasAttribute(name) || el.hasAttribute("data-" + name) || el.hasAttribute("data-ruflet-" + name);
22
+ }
23
+ function readJSON(raw) {
24
+ if (!raw) return {};
25
+ try { return JSON.parse(raw); } catch (_) { return {}; }
26
+ }
27
+
28
+ function syncChrome() {
29
+ var bar = document.querySelector("[ruflet-appbar],[data-ruflet-appbar]");
30
+ if (bar) {
31
+ bar.style.display = "none";
32
+ var appbar = readJSON(attr(bar, "ruflet-appbar"));
33
+ var heading = bar.querySelector("h1,h2,.title");
34
+ var title = appbar.title || attr(bar, "ruflet-title") || (heading ? heading.textContent : "") || document.title || "";
35
+ var leading = appbar.leading || null;
36
+ var leadingEl = bar.querySelector("[ruflet-leading],[data-ruflet-leading]");
37
+ if (leadingEl) leading = readJSON(attr(leadingEl, "ruflet-leading"));
38
+ var actions = [];
39
+ bar.querySelectorAll("[ruflet-icon],[data-ruflet-icon]").forEach(function (el) {
40
+ var icon = readJSON(attr(el, "ruflet-icon"));
41
+ // Carry the whole payload through (e.g. the title/leading the
42
+ // action declares for the screen it pushes), then normalize the
43
+ // icon/url/action the AppBar button itself needs.
44
+ var entry = Object.assign({}, icon);
45
+ entry.icon = icon.icon || attr(el, "ruflet-icon");
46
+ entry.url = el.getAttribute("href") || icon.url || attr(el, "ruflet-url") || "";
47
+ entry.action = icon.action || attr(el, "ruflet-action") || "push";
48
+ actions.push(entry);
49
+ });
50
+ if (appbar.actions && appbar.actions.length) actions = appbar.actions.concat(actions);
51
+ report("appbar", JSON.stringify({ title: (title || "").trim(), leading: leading, actions: actions }));
52
+ } else {
53
+ report("appbar", JSON.stringify({ absent: true }));
54
+ }
55
+ var nav = document.querySelector("[ruflet-tabs],[data-ruflet-tabs]");
56
+ if (nav) {
57
+ nav.style.display = "none";
58
+ var navSpec = readJSON(attr(nav, "ruflet-tabs"));
59
+ var items = [];
60
+ nav.querySelectorAll("a[href]").forEach(function (a) {
61
+ var icon = readJSON(attr(a, "ruflet-icon"));
62
+ var entry = Object.assign({}, icon);
63
+ entry.label = icon.label || attr(a, "ruflet-label") || a.textContent.trim();
64
+ entry.icon = icon.icon || attr(a, "ruflet-icon") || "circle";
65
+ entry.url = a.getAttribute("href");
66
+ entry.selected = has(a, "ruflet-selected");
67
+ items.push(entry);
68
+ });
69
+ if (items.length >= 2) report("bottomnav", JSON.stringify(Object.assign({}, navSpec, { items: items })));
70
+ } else {
71
+ report("bottomnav", JSON.stringify({ absent: true }));
72
+ }
73
+ var drawer = document.querySelector("[ruflet-drawer],[data-ruflet-drawer]");
74
+ if (drawer) {
75
+ drawer.style.display = "none";
76
+ var drawerSpec = readJSON(attr(drawer, "ruflet-drawer"));
77
+ var drawerItems = [];
78
+ drawer.querySelectorAll("a[href]").forEach(function (a) {
79
+ var icon = readJSON(attr(a, "ruflet-icon"));
80
+ var entry = Object.assign({}, icon);
81
+ entry.label = icon.label || attr(a, "ruflet-label") || a.textContent.trim();
82
+ entry.icon = icon.icon || attr(a, "ruflet-icon") || "circle";
83
+ entry.url = a.getAttribute("href");
84
+ entry.action = icon.action || attr(a, "ruflet-action") || drawerSpec.action || "root";
85
+ entry.selected = has(a, "ruflet-selected");
86
+ drawerItems.push(entry);
87
+ });
88
+ if (drawerItems.length) report("drawer", JSON.stringify(Object.assign({}, drawerSpec, { items: drawerItems })));
89
+ } else {
90
+ report("drawer", JSON.stringify({ absent: true }));
91
+ }
92
+ var rail = document.querySelector("[ruflet-rail],[data-ruflet-rail]");
93
+ if (rail) {
94
+ rail.style.display = "none";
95
+ var railSpec = readJSON(attr(rail, "ruflet-rail"));
96
+ if (railSpec.breakpoint && window.innerWidth < Number(railSpec.breakpoint)) {
97
+ report("rail", JSON.stringify({ absent: true }));
98
+ return;
99
+ }
100
+ var railItems = [];
101
+ rail.querySelectorAll("a[href]").forEach(function (a) {
102
+ var icon = readJSON(attr(a, "ruflet-icon"));
103
+ var entry = Object.assign({}, icon);
104
+ entry.label = icon.label || attr(a, "ruflet-label") || a.textContent.trim();
105
+ entry.icon = icon.icon || attr(a, "ruflet-icon") || "circle";
106
+ entry.url = a.getAttribute("href");
107
+ entry.action = icon.action || attr(a, "ruflet-action") || railSpec.action || "root";
108
+ entry.selected = has(a, "ruflet-selected");
109
+ railItems.push(entry);
110
+ });
111
+ if (railItems.length >= 2) report("rail", JSON.stringify(Object.assign({}, railSpec, { items: railItems })));
112
+ } else {
113
+ report("rail", JSON.stringify({ absent: true }));
114
+ }
115
+ }
116
+ syncChrome();
117
+
118
+ if (window.__rufletHtmlAdapterBound) return;
119
+ window.__rufletHtmlAdapterBound = true;
120
+
121
+ document.addEventListener("click", function (e) {
122
+ var t = e.target;
123
+ var nav = t && t.closest ? t.closest("[ruflet-screen],[data-ruflet-screen]") : null;
124
+ if (nav) {
125
+ e.preventDefault();
126
+ var spec = readJSON(attr(nav, "ruflet-screen"));
127
+ spec.component = spec.component || "navigation";
128
+ spec.url = nav.getAttribute("href") || spec.url || attr(nav, "ruflet-url") || "";
129
+ report("action", JSON.stringify(spec));
130
+ return;
131
+ }
132
+
133
+ var el = t && t.closest ? t.closest("[ruflet-action],[data-ruflet-action]") : null;
134
+ if (el) {
135
+ e.preventDefault();
136
+ var payload = readJSON(attr(el, "ruflet-action"));
137
+ payload.url = el.getAttribute("href") || payload.url || attr(el, "ruflet-url") || "";
138
+ report("action", JSON.stringify(payload));
139
+ return;
140
+ }
141
+
142
+ if (rufletInsideSheet) {
143
+ var link = t && t.closest ? t.closest("a[href]") : null;
144
+ if (link) {
145
+ var href = link.getAttribute("href") || "";
146
+ var target = link.getAttribute("target") || "";
147
+ if (href && href !== "#" && target !== "_blank" && !/^(mailto:|tel:|sms:|javascript:)/i.test(href)) {
148
+ e.preventDefault();
149
+ var linkSpec = readJSON(attr(link, "ruflet-link"));
150
+ linkSpec.component = linkSpec.component || "navigation";
151
+ linkSpec.action = linkSpec.action || attr(link, "ruflet-mode") || attr(link, "ruflet-nav") || "root";
152
+ linkSpec.url = href;
153
+ linkSpec.label = linkSpec.label || link.textContent.trim();
154
+ if (attr(link, "ruflet-close") != null) linkSpec.close = attr(link, "ruflet-close");
155
+ report("action", JSON.stringify(linkSpec));
156
+ return;
157
+ }
158
+ }
159
+ }
160
+ }, true);
161
+ })();
162
+ JS
163
+
164
+ ACTION_PREFIX = "ruflet:action:"
165
+ APPBAR_PREFIX = "ruflet:appbar:"
166
+ BOTTOMNAV_PREFIX = "ruflet:bottomnav:"
167
+ DRAWER_PREFIX = "ruflet:drawer:"
168
+ RAIL_PREFIX = "ruflet:rail:"
169
+
170
+ def self.html_adapter_js(sheet: false)
171
+ HTML_ADAPTER_JS.sub("__RUFLET_INSIDE_SHEET__", sheet ? "true" : "false")
172
+ end
173
+
174
+ private
175
+
176
+ # --- HTML adapter ------------------------------------------------------
177
+
178
+ def inject_html_adapter(screen)
179
+ inject_html_adapter_for(screen.webview)
180
+ rescue StandardError
181
+ nil
182
+ end
183
+
184
+ def inject_html_adapter_for(webview, sheet: false)
185
+ webview.run_javascript(self.class.html_adapter_js(sheet: sheet)) if webview.respond_to?(:run_javascript)
186
+ rescue StandardError
187
+ nil
188
+ end
189
+
190
+ # Force JavaScript on for a (mounted) webview. The mobile client only
191
+ # enables JS through this invoke method — the enable_javascript prop is a
192
+ # no-op there — so we call it on every page start.
193
+ def enable_js(webview)
194
+ webview.set_javascript_mode("unrestricted") if webview.respond_to?(:set_javascript_mode)
195
+ rescue StandardError
196
+ nil
197
+ end
198
+
199
+ def handle_message(screen, message)
200
+ return if message.nil?
201
+
202
+ if message.start_with?(ACTION_PREFIX)
203
+ spec = parse_json(message[ACTION_PREFIX.length..])
204
+ dispatch_action(spec) if spec
205
+ elsif message.start_with?(APPBAR_PREFIX)
206
+ spec = parse_json(message[APPBAR_PREFIX.length..])
207
+ apply_appbar(screen, spec) if spec
208
+ elsif message.start_with?(BOTTOMNAV_PREFIX)
209
+ spec = parse_json(message[BOTTOMNAV_PREFIX.length..])
210
+ apply_bottomnav(spec) if spec
211
+ elsif message.start_with?(DRAWER_PREFIX)
212
+ spec = parse_json(message[DRAWER_PREFIX.length..])
213
+ apply_drawer(screen, spec) if spec
214
+ elsif message.start_with?(RAIL_PREFIX)
215
+ spec = parse_json(message[RAIL_PREFIX.length..])
216
+ apply_rail(screen, spec) if spec
217
+ end
218
+ end
219
+
220
+ def handle_sheet_message(message)
221
+ return if message.nil?
222
+
223
+ if message.start_with?(ACTION_PREFIX)
224
+ spec = parse_json(message[ACTION_PREFIX.length..])
225
+ if spec
226
+ if close_sheet_for?(spec)
227
+ close_sheet { dispatch_action(spec) }
228
+ else
229
+ dispatch_action(spec)
230
+ end
231
+ end
232
+ elsif message.start_with?(APPBAR_PREFIX) || message.start_with?(BOTTOMNAV_PREFIX) ||
233
+ message.start_with?(DRAWER_PREFIX) || message.start_with?(RAIL_PREFIX)
234
+ # Chrome declarations inside a modal sheet describe the sheet body,
235
+ # not the app shell. Ignore them; the host screen owns native chrome.
236
+ nil
237
+ end
238
+ end
239
+
240
+ def parse_json(raw)
241
+ value = JSON.parse(raw.to_s)
242
+ value.is_a?(Hash) ? value : nil
243
+ rescue JSON::ParserError
244
+ nil
245
+ end
246
+ end
247
+ end
248
+ end