ruby_everywhere 0.2.0 → 0.3.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 +4 -4
- data/bridge/README.md +48 -0
- data/bridge/everywhere/bridge.js +328 -0
- data/bridge/everywhere/native.css +203 -0
- data/bridge/package.json +6 -3
- data/lib/everywhere/builders/ios.rb +32 -0
- data/lib/everywhere/config.rb +104 -1
- data/lib/everywhere/engine.rb +9 -0
- data/lib/everywhere/mobile_config_endpoint.rb +13 -0
- data/lib/everywhere/mobile_configs_controller.rb +18 -0
- data/lib/everywhere/native_helper.rb +196 -0
- data/lib/everywhere/version.rb +1 -1
- data/support/mobile/ios/App/AppDelegate.swift +2 -1
- data/support/mobile/ios/App/Bridge/MenuComponent.swift +192 -0
- data/support/mobile/ios/App/EverywhereConfig.swift +21 -0
- data/support/mobile/ios/App/SceneDelegate.swift +41 -0
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
- data/support/mobile/ios/README.md +7 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b255975aa8724cb4980606ddbde415eea3a238c193ffce8530028ef5fdc63c24
|
|
4
|
+
data.tar.gz: 386145d42d750984da4fa800ea9b63417371937dbf67d49642a229cd92cfbb71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6af24db6e60c693ae855fa47832564074d887c18248a63be23c343c5e85c7d84cf4210017ba6bb26dae9950bb6b1394255f02cbea4d278f9407952a22f37dd6c
|
|
7
|
+
data.tar.gz: 368f12504a2a5748aa30eaf641eadf5b85bbd54b99e0f372015b9621510f01dc5779ca6e3e37cfab594083768815e40e48200e2c3649195c5b5e800cc7cf5557
|
data/bridge/README.md
CHANGED
|
@@ -33,6 +33,11 @@ Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when pos
|
|
|
33
33
|
Everywhere.on("menu", handler) // shell events; returns an unsubscribe fn
|
|
34
34
|
Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
35
35
|
|
|
36
|
+
Everywhere.menu({ title, items }) // Promise<item | null> — native action
|
|
37
|
+
// sheet in the shell, a styled bottom
|
|
38
|
+
// sheet everywhere else. items:
|
|
39
|
+
// [{ title, id?, style?, disabled? }]
|
|
40
|
+
|
|
36
41
|
Everywhere.clipboard.write(text) // Promise<void>
|
|
37
42
|
Everywhere.clipboard.read() // Promise<string | null>
|
|
38
43
|
|
|
@@ -61,6 +66,49 @@ Everywhere.storage.clear() // Promise<boolean> — clears app keys onl
|
|
|
61
66
|
// desktop shell. Settings, not secrets — tokens go in biometrics.credential.
|
|
62
67
|
```
|
|
63
68
|
|
|
69
|
+
### Native chrome (nav bar buttons, menus, action sheets)
|
|
70
|
+
|
|
71
|
+
Tag ordinary markup, and inside the mobile shell the bridge lifts it into real
|
|
72
|
+
native controls — a nav-bar button, a pull-down / overflow menu, or an action
|
|
73
|
+
sheet. Tapping the native control just `.click()`s the element it mirrors, so a
|
|
74
|
+
link navigates, a submit submits, and behavior stays defined once in the DOM. In
|
|
75
|
+
a browser the same markup is the plain HTML it already is. No JavaScript, no
|
|
76
|
+
Stimulus — CSP-safe.
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<!-- becomes a nav-bar button (right side); icons are per-platform like tabs -->
|
|
80
|
+
<a href="/notes/new" data-everywhere-nav-button
|
|
81
|
+
data-everywhere-nav-title="New"
|
|
82
|
+
data-everywhere-nav-icon-ios="plus"
|
|
83
|
+
data-everywhere-nav-icon-android="add">New</a>
|
|
84
|
+
|
|
85
|
+
<!-- a submit button in the nav bar -->
|
|
86
|
+
<button type="submit" data-everywhere-nav-button
|
|
87
|
+
data-everywhere-nav-title="Save" data-everywhere-nav-style="done">Save</button>
|
|
88
|
+
|
|
89
|
+
<!-- a nav-bar pull-down / overflow menu (defaults to the ⋯ icon) -->
|
|
90
|
+
<div data-everywhere-nav-menu data-everywhere-nav-title="More">
|
|
91
|
+
<a href="/share" data-everywhere-menu-item data-everywhere-menu-title="Share">Share</a>
|
|
92
|
+
<button form="delete_1" data-everywhere-menu-item
|
|
93
|
+
data-everywhere-menu-title="Delete"
|
|
94
|
+
data-everywhere-menu-style="destructive">Delete</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- an in-content action sheet: a trigger and the items it opens -->
|
|
98
|
+
<div data-everywhere-menu>
|
|
99
|
+
<button data-everywhere-menu-trigger>Options</button>
|
|
100
|
+
<div data-everywhere-menu-items>
|
|
101
|
+
<a href="/x/edit" data-everywhere-menu-item>Edit</a>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Rails ships helpers that emit exactly this — `everywhere_nav_button`,
|
|
107
|
+
`everywhere_submit_button`, `everywhere_nav_menu` / `everywhere_menu_item`,
|
|
108
|
+
`everywhere_menu`, and `everywhere_fab` (a floating action button). Include
|
|
109
|
+
`everywhere/native.css` for the FAB, the action-sheet fallback, and the
|
|
110
|
+
safe-area utilities.
|
|
111
|
+
|
|
64
112
|
### Multi-instance apps
|
|
65
113
|
|
|
66
114
|
Apps whose users pick a server — one hosted platform, many instances — opt in
|
data/bridge/everywhere/bridge.js
CHANGED
|
@@ -103,6 +103,50 @@
|
|
|
103
103
|
// notes is the markdown source; notesHtml is the same notes pre-rendered to
|
|
104
104
|
// HTML (from the signed update feed — your own content), ready for a
|
|
105
105
|
// changelog modal: el.innerHTML = notesHtml.
|
|
106
|
+
//
|
|
107
|
+
// Everywhere.menu({ title, message, items }) // Promise<item|null>
|
|
108
|
+
// items: [{ title, id?, style?, disabled? }] style: "destructive" | "cancel"
|
|
109
|
+
// Native action sheet in the mobile shell; a styled bottom sheet everywhere
|
|
110
|
+
// else. Resolves the chosen item (null on cancel/dismiss).
|
|
111
|
+
//
|
|
112
|
+
// Native chrome, declarative (no JS) — the bridge watches every page and,
|
|
113
|
+
// inside the mobile shell, lifts these into real native controls; in a
|
|
114
|
+
// browser (and the desktop shell) they stay as the plain HTML they already
|
|
115
|
+
// are, so the same markup works everywhere. Tapping a native control just
|
|
116
|
+
// .click()s the element it mirrors — a link navigates, a submit submits, a
|
|
117
|
+
// button fires its handler — so behavior is defined once, in the DOM.
|
|
118
|
+
//
|
|
119
|
+
// Nav bar button (right side by default):
|
|
120
|
+
// <a href="/notes/new" data-everywhere-nav-button
|
|
121
|
+
// data-everywhere-nav-title="New" data-everywhere-nav-icon="plus">New</a>
|
|
122
|
+
// Nav bar submit button (submits the form when tapped):
|
|
123
|
+
// <button type="submit" data-everywhere-nav-button
|
|
124
|
+
// data-everywhere-nav-title="Save" data-everywhere-nav-style="done">Save</button>
|
|
125
|
+
// Nav bar pull-down / overflow menu (defaults to the ⋯ ellipsis icon):
|
|
126
|
+
// <div data-everywhere-nav-menu data-everywhere-nav-title="More">
|
|
127
|
+
// <a href="/share" data-everywhere-menu-item data-everywhere-menu-title="Share"
|
|
128
|
+
// data-everywhere-menu-icon-ios="square.and.arrow.up"
|
|
129
|
+
// data-everywhere-menu-icon-android="share">Share</a>
|
|
130
|
+
// <button form="delete_1" data-everywhere-menu-item data-everywhere-menu-title="Delete"
|
|
131
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
132
|
+
// </div>
|
|
133
|
+
// In-content action sheet (a trigger + the items it opens):
|
|
134
|
+
// <div data-everywhere-menu>
|
|
135
|
+
// <button data-everywhere-menu-trigger>Options</button>
|
|
136
|
+
// <div data-everywhere-menu-items>
|
|
137
|
+
// <a href="/x/edit" data-everywhere-menu-item>Edit</a>
|
|
138
|
+
// <button form="del" data-everywhere-menu-item
|
|
139
|
+
// data-everywhere-menu-style="destructive">Delete</button>
|
|
140
|
+
// </div>
|
|
141
|
+
// </div>
|
|
142
|
+
// Icons are per-platform, like tabs: -icon-ios is an SF Symbol, -icon-android
|
|
143
|
+
// a Material name, and a plain -icon is the shared fallback; the bridge picks
|
|
144
|
+
// by the running os, so one page serves both shells. Icons are ignored on the
|
|
145
|
+
// web. side is "left" or "right" (default). Nav-bar sources are hidden in the
|
|
146
|
+
// shell (they live in the bar now) but shown in a browser; the action-sheet
|
|
147
|
+
// trigger toggles its items as an inline menu when there's no native shell.
|
|
148
|
+
// These are the everywhere_nav_button / everywhere_nav_menu /
|
|
149
|
+
// everywhere_submit_button / everywhere_menu Rails helpers.
|
|
106
150
|
|
|
107
151
|
const config =
|
|
108
152
|
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
@@ -395,6 +439,7 @@ const HAPTICS_COMPONENT = "everywhere--haptics"
|
|
|
395
439
|
const PERMISSIONS_COMPONENT = "everywhere--permissions"
|
|
396
440
|
const BIOMETRICS_COMPONENT = "everywhere--biometrics"
|
|
397
441
|
const STORAGE_COMPONENT = "everywhere--storage"
|
|
442
|
+
const MENU_COMPONENT = "everywhere--menu"
|
|
398
443
|
|
|
399
444
|
function installWebBridge() {
|
|
400
445
|
if (window.HotwireNative) return
|
|
@@ -633,6 +678,27 @@ const mobile = {
|
|
|
633
678
|
}
|
|
634
679
|
})
|
|
635
680
|
})
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
// Native chrome (nav bar items + action sheets) via the shell's
|
|
684
|
+
// "everywhere--menu" component. Unlike the other components these replies are
|
|
685
|
+
// not one-shot: native re-replies to the SAME message on every tap, so the
|
|
686
|
+
// caller owns the callback's lifetime (menuRemove) rather than clearing it on
|
|
687
|
+
// first reply.
|
|
688
|
+
menuSupported() {
|
|
689
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
690
|
+
return !!(web && web.supportsComponent(MENU_COMPONENT))
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
menuSend(event, data, callback) {
|
|
694
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
695
|
+
if (!web || !web.supportsComponent(MENU_COMPONENT)) return null
|
|
696
|
+
return web.send({ component: MENU_COMPONENT, event, data, callback })
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
menuRemove(id) {
|
|
700
|
+
const web = window.HotwireNative && window.HotwireNative.web
|
|
701
|
+
if (web && id !== null && id !== undefined && web.removeCallbackFor) web.removeCallbackFor(id)
|
|
636
702
|
}
|
|
637
703
|
}
|
|
638
704
|
|
|
@@ -689,6 +755,26 @@ export const Everywhere = {
|
|
|
689
755
|
else window.location.assign(path)
|
|
690
756
|
},
|
|
691
757
|
|
|
758
|
+
// A native action sheet in the mobile shell; a styled bottom sheet in the
|
|
759
|
+
// browser and desktop shell. Resolves the chosen item (the same object you
|
|
760
|
+
// passed), or null on cancel/dismiss:
|
|
761
|
+
//
|
|
762
|
+
// const choice = await Everywhere.menu({
|
|
763
|
+
// title: "Post",
|
|
764
|
+
// items: [{ id: "share", title: "Share" },
|
|
765
|
+
// { id: "delete", title: "Delete", style: "destructive" }]
|
|
766
|
+
// })
|
|
767
|
+
// if (choice?.id === "delete") …
|
|
768
|
+
menu(options = {}) {
|
|
769
|
+
const items = Array.isArray(options.items) ? options.items : []
|
|
770
|
+
return presentMenu({
|
|
771
|
+
title: options.title,
|
|
772
|
+
message: options.message,
|
|
773
|
+
items,
|
|
774
|
+
source: options.source
|
|
775
|
+
}).then((id) => (id == null ? null : items.find((item) => String(item.id) === String(id)) || null))
|
|
776
|
+
},
|
|
777
|
+
|
|
692
778
|
// Refresh the native tab bar / navigation config now (mobile only; a no-op
|
|
693
779
|
// in the browser and desktop shell, where there's nothing to refresh).
|
|
694
780
|
reloadTabs() {
|
|
@@ -1032,10 +1118,237 @@ async function wireBiometricToggles() {
|
|
|
1032
1118
|
})
|
|
1033
1119
|
}
|
|
1034
1120
|
|
|
1121
|
+
// --- native chrome (menus, nav bar items) -----------------------------------
|
|
1122
|
+
// Picks the native action sheet in the mobile shell and a DOM bottom sheet
|
|
1123
|
+
// (native.css .everywhere-sheet) everywhere else. Resolves the chosen item's
|
|
1124
|
+
// id, or null on cancel/dismiss.
|
|
1125
|
+
function presentMenu({ title, message, items, source }) {
|
|
1126
|
+
const list = (items || []).map((item, i) => ({
|
|
1127
|
+
id: item.id != null ? String(item.id) : String(i),
|
|
1128
|
+
title: item.title || "",
|
|
1129
|
+
image: item.image || null,
|
|
1130
|
+
style: item.style || "default",
|
|
1131
|
+
disabled: !!item.disabled
|
|
1132
|
+
}))
|
|
1133
|
+
|
|
1134
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1135
|
+
return new Promise((resolve) => {
|
|
1136
|
+
let settled = false
|
|
1137
|
+
const id = adapter.menuSend("menu", { title, message, items: list, source }, (message) => {
|
|
1138
|
+
if (settled) return
|
|
1139
|
+
settled = true
|
|
1140
|
+
adapter.menuRemove(id)
|
|
1141
|
+
const data = (message && message.data) || {}
|
|
1142
|
+
resolve(data.action === "select" && data.id != null ? String(data.id) : null)
|
|
1143
|
+
})
|
|
1144
|
+
if (id === null) resolve(domMenu({ title, message, items: list }))
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
return domMenu({ title, message, items: list })
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// The honest web/desktop fallback for Everywhere.menu(): a modal bottom sheet
|
|
1151
|
+
// built from the same item list. No inline scripts — pure DOM — so it clears CSP.
|
|
1152
|
+
function domMenu({ title, message, items }) {
|
|
1153
|
+
if (typeof document === "undefined") return Promise.resolve(null)
|
|
1154
|
+
|
|
1155
|
+
return new Promise((resolve) => {
|
|
1156
|
+
const overlay = document.createElement("div")
|
|
1157
|
+
overlay.className = "everywhere-sheet-overlay"
|
|
1158
|
+
const sheet = document.createElement("div")
|
|
1159
|
+
sheet.className = "everywhere-sheet"
|
|
1160
|
+
sheet.setAttribute("role", "menu")
|
|
1161
|
+
|
|
1162
|
+
if (title || message) {
|
|
1163
|
+
const header = document.createElement("div")
|
|
1164
|
+
header.className = "everywhere-sheet-header"
|
|
1165
|
+
if (title) {
|
|
1166
|
+
const el = document.createElement("div")
|
|
1167
|
+
el.className = "everywhere-sheet-title"
|
|
1168
|
+
el.textContent = title
|
|
1169
|
+
header.appendChild(el)
|
|
1170
|
+
}
|
|
1171
|
+
if (message) {
|
|
1172
|
+
const el = document.createElement("div")
|
|
1173
|
+
el.className = "everywhere-sheet-message"
|
|
1174
|
+
el.textContent = message
|
|
1175
|
+
header.appendChild(el)
|
|
1176
|
+
}
|
|
1177
|
+
sheet.appendChild(header)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
let done = false
|
|
1181
|
+
const close = (value) => {
|
|
1182
|
+
if (done) return
|
|
1183
|
+
done = true
|
|
1184
|
+
document.removeEventListener("keydown", onKey)
|
|
1185
|
+
overlay.remove()
|
|
1186
|
+
resolve(value)
|
|
1187
|
+
}
|
|
1188
|
+
const onKey = (event) => { if (event.key === "Escape") close(null) }
|
|
1189
|
+
|
|
1190
|
+
items.forEach((item) => {
|
|
1191
|
+
const button = document.createElement("button")
|
|
1192
|
+
button.type = "button"
|
|
1193
|
+
button.className = "everywhere-sheet-item" +
|
|
1194
|
+
(item.style === "destructive" ? " everywhere-sheet-item-destructive" : "")
|
|
1195
|
+
button.textContent = item.title
|
|
1196
|
+
button.setAttribute("role", "menuitem")
|
|
1197
|
+
if (item.disabled) button.disabled = true
|
|
1198
|
+
else button.addEventListener("click", () => close(item.id))
|
|
1199
|
+
sheet.appendChild(button)
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
const cancel = document.createElement("button")
|
|
1203
|
+
cancel.type = "button"
|
|
1204
|
+
cancel.className = "everywhere-sheet-item everywhere-sheet-cancel"
|
|
1205
|
+
cancel.textContent = "Cancel"
|
|
1206
|
+
cancel.addEventListener("click", () => close(null))
|
|
1207
|
+
sheet.appendChild(cancel)
|
|
1208
|
+
|
|
1209
|
+
overlay.addEventListener("click", (event) => { if (event.target === overlay) close(null) })
|
|
1210
|
+
document.addEventListener("keydown", onKey)
|
|
1211
|
+
overlay.appendChild(sheet)
|
|
1212
|
+
document.body.appendChild(overlay)
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Per-platform icon, resolved like tabs: `<base>-<os>` (SF Symbol on iOS, a
|
|
1217
|
+
// Material name on Android) then the shared `<base>`. One page serves both shells.
|
|
1218
|
+
function resolveNativeIcon(el, base) {
|
|
1219
|
+
return el.getAttribute(`${base}-${os}`) || el.getAttribute(base) || null
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function navText(el) {
|
|
1223
|
+
return (el.getAttribute("data-everywhere-nav-title") || el.textContent || "").trim()
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function navSide(el) {
|
|
1227
|
+
return el.getAttribute("data-everywhere-nav-side") === "left" ? "left" : "right"
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function menuItemTitle(el) {
|
|
1231
|
+
return (el.getAttribute("data-everywhere-menu-title") || el.textContent || "").trim()
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// The nav bar items the current page declares, plus a fresh id→element map so
|
|
1235
|
+
// native taps can .click() the source element. Menu children get ids like
|
|
1236
|
+
// "n2.0" so a pull-down item routes back to the right element.
|
|
1237
|
+
let navMessageId = null
|
|
1238
|
+
const navElements = new Map()
|
|
1239
|
+
|
|
1240
|
+
function collectNavItems() {
|
|
1241
|
+
navElements.clear()
|
|
1242
|
+
const items = []
|
|
1243
|
+
let n = 0
|
|
1244
|
+
|
|
1245
|
+
document.querySelectorAll("[data-everywhere-nav-button]").forEach((el) => {
|
|
1246
|
+
const id = "n" + (n++)
|
|
1247
|
+
navElements.set(id, el)
|
|
1248
|
+
items.push({
|
|
1249
|
+
id,
|
|
1250
|
+
kind: "button",
|
|
1251
|
+
title: navText(el),
|
|
1252
|
+
image: resolveNativeIcon(el, "data-everywhere-nav-icon"),
|
|
1253
|
+
side: navSide(el),
|
|
1254
|
+
style: el.getAttribute("data-everywhere-nav-style") || "plain",
|
|
1255
|
+
disabled: el.disabled === true || el.hasAttribute("data-everywhere-nav-disabled")
|
|
1256
|
+
})
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
document.querySelectorAll("[data-everywhere-nav-menu]").forEach((menuEl) => {
|
|
1260
|
+
const id = "n" + (n++)
|
|
1261
|
+
const children = []
|
|
1262
|
+
menuEl.querySelectorAll("[data-everywhere-menu-item]").forEach((child, i) => {
|
|
1263
|
+
const cid = id + "." + i
|
|
1264
|
+
navElements.set(cid, child)
|
|
1265
|
+
children.push({
|
|
1266
|
+
id: cid,
|
|
1267
|
+
title: menuItemTitle(child),
|
|
1268
|
+
image: resolveNativeIcon(child, "data-everywhere-menu-icon"),
|
|
1269
|
+
style: child.getAttribute("data-everywhere-menu-style") || "default"
|
|
1270
|
+
})
|
|
1271
|
+
})
|
|
1272
|
+
items.push({
|
|
1273
|
+
id,
|
|
1274
|
+
kind: "menu",
|
|
1275
|
+
title: navText(menuEl),
|
|
1276
|
+
image: resolveNativeIcon(menuEl, "data-everywhere-nav-icon") || "ellipsis.circle",
|
|
1277
|
+
side: navSide(menuEl),
|
|
1278
|
+
children
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
return items
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Send this page's nav bar items to the shell. Native re-replies on every tap,
|
|
1286
|
+
// so the callback lives until the next page swaps it out (menuRemove first).
|
|
1287
|
+
function applyNavItems() {
|
|
1288
|
+
if (platform !== "mobile" || !adapter.menuSupported || !adapter.menuSupported()) return
|
|
1289
|
+
if (navMessageId !== null) {
|
|
1290
|
+
adapter.menuRemove(navMessageId)
|
|
1291
|
+
navMessageId = null
|
|
1292
|
+
}
|
|
1293
|
+
const items = collectNavItems()
|
|
1294
|
+
if (!items.length) return
|
|
1295
|
+
|
|
1296
|
+
navMessageId = adapter.menuSend("navItems", { items }, (message) => {
|
|
1297
|
+
const data = (message && message.data) || {}
|
|
1298
|
+
if (data.action === "tap" && data.id != null) {
|
|
1299
|
+
const el = navElements.get(String(data.id))
|
|
1300
|
+
if (el) el.click()
|
|
1301
|
+
}
|
|
1302
|
+
})
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// A tap on an in-content [data-everywhere-menu-trigger]: a native action sheet
|
|
1306
|
+
// in the shell, an inline disclosure (toggling [data-everywhere-menu-open]) in
|
|
1307
|
+
// a browser. Either way, choosing an item .click()s the element it maps to.
|
|
1308
|
+
function handleMenuTrigger(event, trigger) {
|
|
1309
|
+
const container = trigger.closest("[data-everywhere-menu]") || trigger.parentElement
|
|
1310
|
+
if (!container) return
|
|
1311
|
+
const holder = container.querySelector("[data-everywhere-menu-items]") || container
|
|
1312
|
+
const elements = Array.from(holder.querySelectorAll("[data-everywhere-menu-item]"))
|
|
1313
|
+
|
|
1314
|
+
if (platform === "mobile" && adapter.menuSupported && adapter.menuSupported()) {
|
|
1315
|
+
event.preventDefault()
|
|
1316
|
+
const rect = trigger.getBoundingClientRect()
|
|
1317
|
+
const items = elements.map((el, i) => ({
|
|
1318
|
+
id: String(i),
|
|
1319
|
+
title: menuItemTitle(el),
|
|
1320
|
+
image: resolveNativeIcon(el, "data-everywhere-menu-icon"),
|
|
1321
|
+
style: el.getAttribute("data-everywhere-menu-style") || "default",
|
|
1322
|
+
disabled: el.disabled === true
|
|
1323
|
+
}))
|
|
1324
|
+
presentMenu({
|
|
1325
|
+
title: trigger.getAttribute("data-everywhere-menu-title") ||
|
|
1326
|
+
container.getAttribute("data-everywhere-menu-title") || null,
|
|
1327
|
+
items,
|
|
1328
|
+
source: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }
|
|
1329
|
+
}).then((id) => {
|
|
1330
|
+
if (id == null) return
|
|
1331
|
+
const el = elements[parseInt(id, 10)]
|
|
1332
|
+
if (el) el.click()
|
|
1333
|
+
})
|
|
1334
|
+
} else {
|
|
1335
|
+
event.preventDefault()
|
|
1336
|
+
const open = container.hasAttribute("data-everywhere-menu-open")
|
|
1337
|
+
closeInlineMenus()
|
|
1338
|
+
if (!open) container.setAttribute("data-everywhere-menu-open", "")
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function closeInlineMenus() {
|
|
1343
|
+
document.querySelectorAll("[data-everywhere-menu-open]")
|
|
1344
|
+
.forEach((el) => el.removeAttribute("data-everywhere-menu-open"))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1035
1347
|
function applyDeclarative() {
|
|
1036
1348
|
applyBadgeMetas()
|
|
1037
1349
|
applyBiometricGates()
|
|
1038
1350
|
wireBiometricToggles()
|
|
1351
|
+
applyNavItems()
|
|
1039
1352
|
}
|
|
1040
1353
|
|
|
1041
1354
|
if (typeof document !== "undefined") {
|
|
@@ -1061,6 +1374,13 @@ if (typeof document !== "undefined") {
|
|
|
1061
1374
|
if (content) content.hidden = true
|
|
1062
1375
|
if (overlay) overlay.hidden = true
|
|
1063
1376
|
})
|
|
1377
|
+
// Never snapshot an open inline menu; the next page's applyNavItems()
|
|
1378
|
+
// replaces the nav callback, so drop this page's before it's cached.
|
|
1379
|
+
closeInlineMenus()
|
|
1380
|
+
if (navMessageId !== null) {
|
|
1381
|
+
adapter.menuRemove(navMessageId)
|
|
1382
|
+
navMessageId = null
|
|
1383
|
+
}
|
|
1064
1384
|
})
|
|
1065
1385
|
|
|
1066
1386
|
document.addEventListener("click", (event) => {
|
|
@@ -1071,6 +1391,14 @@ if (typeof document !== "undefined") {
|
|
|
1071
1391
|
const unlock = event.target.closest("[data-everywhere-biometric-unlock]")
|
|
1072
1392
|
const gate = unlock && unlock.closest("[data-everywhere-biometric-lock]")
|
|
1073
1393
|
if (gate) resolveBiometricGate(gate, { prompt: true })
|
|
1394
|
+
|
|
1395
|
+
const trigger = event.target.closest("[data-everywhere-menu-trigger]")
|
|
1396
|
+
if (trigger) {
|
|
1397
|
+
handleMenuTrigger(event, trigger)
|
|
1398
|
+
} else if (!event.target.closest("[data-everywhere-menu-open]")) {
|
|
1399
|
+
// A click anywhere outside an open inline menu dismisses it.
|
|
1400
|
+
closeInlineMenus()
|
|
1401
|
+
}
|
|
1074
1402
|
})
|
|
1075
1403
|
|
|
1076
1404
|
document.addEventListener("change", async (event) => {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/* @rubyeverywhere/bridge — safe-area utilities for the native shells.
|
|
2
|
+
*
|
|
3
|
+
* Pair with <meta name="viewport" content="…, viewport-fit=cover"> so the
|
|
4
|
+
* browser exposes the device's safe-area insets. These classes pad content
|
|
5
|
+
* clear of the status bar / Dynamic Island / home indicator without
|
|
6
|
+
* hardcoding device sizes. On the desktop shell and plain browsers the insets
|
|
7
|
+
* are 0, so the classes are inert — safe to apply unconditionally.
|
|
8
|
+
*
|
|
9
|
+
* Include in Rails: <%= stylesheet_link_tag "everywhere/native" %>
|
|
10
|
+
* (Sinatra/Hanami: it's vendored to public/ by `every install`.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root {
|
|
14
|
+
--everywhere-inset-top: env(safe-area-inset-top, 0px);
|
|
15
|
+
--everywhere-inset-right: env(safe-area-inset-right, 0px);
|
|
16
|
+
--everywhere-inset-bottom: env(safe-area-inset-bottom, 0px);
|
|
17
|
+
--everywhere-inset-left: env(safe-area-inset-left, 0px);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Padding utilities — add safe-area room on top of any existing padding. */
|
|
21
|
+
.native-inset {
|
|
22
|
+
padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px));
|
|
23
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px));
|
|
24
|
+
}
|
|
25
|
+
.native-inset-top { padding-top: calc(var(--everywhere-inset-top) + var(--native-inset-pad, 0px)); }
|
|
26
|
+
.native-inset-bottom { padding-bottom: calc(var(--everywhere-inset-bottom) + var(--native-inset-pad, 0px)); }
|
|
27
|
+
.native-inset-left { padding-left: calc(var(--everywhere-inset-left) + var(--native-inset-pad, 0px)); }
|
|
28
|
+
.native-inset-right { padding-right: calc(var(--everywhere-inset-right) + var(--native-inset-pad, 0px)); }
|
|
29
|
+
|
|
30
|
+
/* Margin variants, when padding would fight an existing background. */
|
|
31
|
+
.native-inset-mt { margin-top: var(--everywhere-inset-top); }
|
|
32
|
+
.native-inset-mb { margin-bottom: var(--everywhere-inset-bottom); }
|
|
33
|
+
|
|
34
|
+
/* A bar pinned to the bottom edge that stays above the home indicator. */
|
|
35
|
+
.native-safe-bottom {
|
|
36
|
+
padding-bottom: max(var(--everywhere-inset-bottom), var(--native-safe-min, 0.5rem));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Fixed/absolute elements anchored near the bottom (toasts, nudges, FABs):
|
|
40
|
+
* lifts them clear of the home indicator AND anything overlaying the webview
|
|
41
|
+
* from below — on iOS the floating tab bar contributes to the bottom inset.
|
|
42
|
+
* --native-bottom-gap is the visual gap above that (default 1rem). */
|
|
43
|
+
.native-bottom {
|
|
44
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Full-height element that respects both vertical insets. */
|
|
48
|
+
.native-safe-height {
|
|
49
|
+
min-height: calc(100dvh - var(--everywhere-inset-top) - var(--everywhere-inset-bottom));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ---------------------------------------------------------------------------
|
|
53
|
+
* Native chrome — styling for the everywhere_fab / everywhere_menu helpers and
|
|
54
|
+
* the bridge's DOM action-sheet fallback. Set --everywhere-tint in your own CSS
|
|
55
|
+
* to brand the accent (it defaults to the platform accent color).
|
|
56
|
+
* ------------------------------------------------------------------------- */
|
|
57
|
+
|
|
58
|
+
:root {
|
|
59
|
+
--everywhere-tint: AccentColor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Floating action button (everywhere_fab): fixed, safe-area-aware, above the
|
|
63
|
+
* home indicator and iOS floating tab bar. A real link/button, so it shows in
|
|
64
|
+
* a browser too. */
|
|
65
|
+
.everywhere-fab {
|
|
66
|
+
position: fixed;
|
|
67
|
+
right: max(var(--everywhere-inset-right), 1rem);
|
|
68
|
+
bottom: calc(var(--everywhere-inset-bottom) + var(--native-bottom-gap, 1rem));
|
|
69
|
+
z-index: 1000;
|
|
70
|
+
display: inline-flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
gap: 0.5rem;
|
|
74
|
+
width: 3.5rem;
|
|
75
|
+
height: 3.5rem;
|
|
76
|
+
padding: 0;
|
|
77
|
+
border: 0;
|
|
78
|
+
border-radius: 9999px;
|
|
79
|
+
background: var(--everywhere-fab-bg, var(--everywhere-tint, #1a73e8));
|
|
80
|
+
color: var(--everywhere-fab-color, #fff);
|
|
81
|
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
-webkit-tap-highlight-color: transparent;
|
|
84
|
+
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
85
|
+
}
|
|
86
|
+
.everywhere-fab:active {
|
|
87
|
+
transform: scale(0.94);
|
|
88
|
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3);
|
|
89
|
+
}
|
|
90
|
+
.everywhere-fab-left {
|
|
91
|
+
right: auto;
|
|
92
|
+
left: max(var(--everywhere-inset-left), 1rem);
|
|
93
|
+
}
|
|
94
|
+
/* Extended FAB: pill with a visible label beside the icon. */
|
|
95
|
+
.everywhere-fab-extended {
|
|
96
|
+
width: auto;
|
|
97
|
+
height: 3.25rem;
|
|
98
|
+
padding: 0 1.25rem;
|
|
99
|
+
font: inherit;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
}
|
|
102
|
+
.everywhere-fab-icon {
|
|
103
|
+
width: 1.5rem;
|
|
104
|
+
height: 1.5rem;
|
|
105
|
+
flex: none;
|
|
106
|
+
}
|
|
107
|
+
.everywhere-fab-label {
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* In-content action sheet (everywhere_menu) inline fallback: the items stay
|
|
112
|
+
* hidden until the trigger opens them. In the native shell the bridge shows a
|
|
113
|
+
* real action sheet instead and these never appear. */
|
|
114
|
+
[data-everywhere-menu-items] {
|
|
115
|
+
display: none;
|
|
116
|
+
}
|
|
117
|
+
[data-everywhere-menu-open] > [data-everywhere-menu-items],
|
|
118
|
+
[data-everywhere-menu-open] [data-everywhere-menu-items] {
|
|
119
|
+
display: block;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Nav-bar sources (everywhere_nav_button / everywhere_nav_menu) live in the
|
|
123
|
+
* top bar inside the shell, so hide the in-page copy there — but keep it in a
|
|
124
|
+
* browser, where there is no native bar. Keyed on the bridge's platform marker
|
|
125
|
+
* (set only by the mobile web bridge). */
|
|
126
|
+
html[data-bridge-platform] [data-everywhere-nav-button],
|
|
127
|
+
html[data-bridge-platform] [data-everywhere-nav-menu] {
|
|
128
|
+
display: none !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* DOM action-sheet fallback rendered by the bridge (Everywhere.menu / the
|
|
132
|
+
* everywhere_menu trigger) when there is no native shell. */
|
|
133
|
+
.everywhere-sheet-overlay {
|
|
134
|
+
position: fixed;
|
|
135
|
+
inset: 0;
|
|
136
|
+
z-index: 2000;
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: flex-end;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
padding: 0.75rem;
|
|
141
|
+
padding-bottom: calc(var(--everywhere-inset-bottom) + 0.75rem);
|
|
142
|
+
background: rgba(0, 0, 0, 0.4);
|
|
143
|
+
animation: everywhere-sheet-fade 0.15s ease;
|
|
144
|
+
}
|
|
145
|
+
.everywhere-sheet {
|
|
146
|
+
width: 100%;
|
|
147
|
+
max-width: 30rem;
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 1px;
|
|
151
|
+
border-radius: 0.9rem;
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
background: rgba(120, 120, 128, 0.2);
|
|
154
|
+
animation: everywhere-sheet-rise 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
155
|
+
}
|
|
156
|
+
.everywhere-sheet-header {
|
|
157
|
+
padding: 0.85rem 1rem;
|
|
158
|
+
text-align: center;
|
|
159
|
+
background: Canvas;
|
|
160
|
+
color: color-mix(in srgb, CanvasText 60%, transparent);
|
|
161
|
+
}
|
|
162
|
+
.everywhere-sheet-title {
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
color: CanvasText;
|
|
165
|
+
}
|
|
166
|
+
.everywhere-sheet-message {
|
|
167
|
+
font-size: 0.85rem;
|
|
168
|
+
margin-top: 0.15rem;
|
|
169
|
+
}
|
|
170
|
+
.everywhere-sheet-item {
|
|
171
|
+
appearance: none;
|
|
172
|
+
border: 0;
|
|
173
|
+
width: 100%;
|
|
174
|
+
padding: 1rem;
|
|
175
|
+
font: inherit;
|
|
176
|
+
font-size: 1.05rem;
|
|
177
|
+
text-align: center;
|
|
178
|
+
background: Canvas;
|
|
179
|
+
color: var(--everywhere-tint, #1a73e8);
|
|
180
|
+
cursor: pointer;
|
|
181
|
+
}
|
|
182
|
+
.everywhere-sheet-item:active {
|
|
183
|
+
background: color-mix(in srgb, CanvasText 8%, Canvas);
|
|
184
|
+
}
|
|
185
|
+
.everywhere-sheet-item:disabled {
|
|
186
|
+
color: color-mix(in srgb, CanvasText 30%, transparent);
|
|
187
|
+
cursor: default;
|
|
188
|
+
}
|
|
189
|
+
.everywhere-sheet-item-destructive {
|
|
190
|
+
color: #e5484d;
|
|
191
|
+
}
|
|
192
|
+
.everywhere-sheet-cancel {
|
|
193
|
+
margin-top: 0.5rem;
|
|
194
|
+
border-radius: 0.9rem;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: CanvasText;
|
|
197
|
+
}
|
|
198
|
+
@keyframes everywhere-sheet-fade {
|
|
199
|
+
from { opacity: 0; }
|
|
200
|
+
}
|
|
201
|
+
@keyframes everywhere-sheet-rise {
|
|
202
|
+
from { transform: translateY(1rem); opacity: 0; }
|
|
203
|
+
}
|
data/bridge/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubyeverywhere/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "One API for browser, desktop, and mobile — the JS half of RubyEverywhere",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "everywhere/bridge.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./everywhere/bridge.js",
|
|
9
|
+
"./native.css": "./everywhere/native.css"
|
|
10
|
+
},
|
|
11
|
+
"files": ["everywhere/bridge.js", "everywhere/native.css", "README.md", "LICENSE"],
|
|
9
12
|
"sideEffects": false,
|
|
10
13
|
"keywords": ["rubyeverywhere", "tauri", "hotwire", "turbo", "rails", "desktop", "native", "bridge"],
|
|
11
14
|
"homepage": "https://rubyeverywhere.com",
|