shimmer 0.0.1 → 0.0.7
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/.editorconfig +12 -0
- data/.eslintrc.js +25 -0
- data/.prettierrc +3 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +4 -0
- data/.vscode/settings.json +3 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +87 -0
- data/README.md +172 -14
- data/lib/shimmer/controllers/files_controller.rb +15 -0
- data/lib/shimmer/controllers/sitemaps_controller.rb +10 -0
- data/lib/shimmer/jobs/sitemap_job.rb +10 -0
- data/lib/shimmer/middlewares/cloudflare.rb +17 -0
- data/lib/shimmer/railtie.rb +8 -0
- data/lib/shimmer/tasks/db.rake +42 -0
- data/lib/shimmer/tasks/lint.rake +9 -0
- data/lib/shimmer/utils/file_helper.rb +27 -0
- data/lib/shimmer/utils/file_proxy.rb +65 -0
- data/lib/shimmer/utils/localizable.rb +58 -0
- data/lib/shimmer/utils/remote_navigation.rb +112 -0
- data/lib/shimmer/utils/sitemap_adapter.rb +10 -0
- data/lib/shimmer/version.rb +1 -1
- data/lib/shimmer.rb +5 -0
- data/package.json +51 -0
- data/rollup.config.js +26 -0
- data/src/controllers/remote-navigation.ts +9 -0
- data/src/index.ts +32 -0
- data/src/locale.ts +3 -0
- data/src/modal.ts +73 -0
- data/src/popover.ts +89 -0
- data/src/serviceworker.ts +7 -0
- data/src/touch.ts +20 -0
- data/src/util.ts +31 -0
- data/tsconfig.json +28 -0
- data/typings.d.ts +1 -0
- data/yarn.lock +1055 -0
- metadata +35 -4
- data/shimmer.gemspec +0 -35
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Shimmer
|
4
|
+
module RemoteNavigation
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
def ui
|
9
|
+
@ui ||= RemoteNavigator.new(self)
|
10
|
+
end
|
11
|
+
|
12
|
+
def default_render
|
13
|
+
return render_modal if shimmer_request?
|
14
|
+
return super unless ui.updates?
|
15
|
+
|
16
|
+
render turbo_stream: ui.queued_updates.join("\n")
|
17
|
+
end
|
18
|
+
|
19
|
+
helper_method :modal_path
|
20
|
+
def modal_path(url, id: nil, size: nil, close: true)
|
21
|
+
"javascript:ui.modal.open(#{{url: url, id: id, size: size, close: close}.to_json})"
|
22
|
+
end
|
23
|
+
|
24
|
+
helper_method :close_modal_path
|
25
|
+
def close_modal_path(id: nil)
|
26
|
+
"javascript:ui.modal.close(#{{id: id}.to_json})"
|
27
|
+
end
|
28
|
+
|
29
|
+
helper_method :popover_path
|
30
|
+
def popover_path(url, id: nil, selector: nil, placement: nil)
|
31
|
+
"javascript:ui.popover.open(#{{url: url, id: id, selector: selector, placement: placement}.compact.to_json})"
|
32
|
+
end
|
33
|
+
|
34
|
+
def shimmer_request?
|
35
|
+
request.headers["X-Shimmer"].present?
|
36
|
+
end
|
37
|
+
|
38
|
+
def render_modal
|
39
|
+
enforce_modal
|
40
|
+
render layout: false
|
41
|
+
end
|
42
|
+
|
43
|
+
def enforce_modal
|
44
|
+
raise "trying to render a modal from a regular request" unless shimmer_request?
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class RemoteNavigator
|
50
|
+
delegate :polymorphic_path, to: :@controller
|
51
|
+
|
52
|
+
def initialize(controller)
|
53
|
+
@controller = controller
|
54
|
+
end
|
55
|
+
|
56
|
+
def queued_updates
|
57
|
+
@queued_updates ||= []
|
58
|
+
end
|
59
|
+
|
60
|
+
def updates?
|
61
|
+
queued_updates.any?
|
62
|
+
end
|
63
|
+
|
64
|
+
def run_javascript(script)
|
65
|
+
queued_updates.push turbo_stream.append "shimmer", "<div class='hidden' data-controller='remote-navigation'>#{script}</div>"
|
66
|
+
end
|
67
|
+
|
68
|
+
def replace(id, with: id, **locals)
|
69
|
+
queued_updates.push turbo_stream.replace(id, partial: with, locals: locals)
|
70
|
+
end
|
71
|
+
|
72
|
+
def prepend(id, with:, **locals)
|
73
|
+
queued_updates.push turbo_stream.prepend(id, partial: with, locals: locals)
|
74
|
+
end
|
75
|
+
|
76
|
+
def append(id, with:, **locals)
|
77
|
+
queued_updates.push turbo_stream.append(id, partial: with, locals: locals)
|
78
|
+
end
|
79
|
+
|
80
|
+
def remove(id)
|
81
|
+
queued_updates.push turbo_stream.remove(id)
|
82
|
+
end
|
83
|
+
|
84
|
+
def open_modal(path, id: nil, size: nil, close: true)
|
85
|
+
run_javascript "ui.modal.open(#{{url: url, id: id, size: size, close: close}.to_json})"
|
86
|
+
end
|
87
|
+
|
88
|
+
def close_modal
|
89
|
+
run_javascript "ui.modal.close()"
|
90
|
+
end
|
91
|
+
|
92
|
+
def open_popover(path, selector:, placement: nil)
|
93
|
+
run_javascript "ui.popover.open(#{{url: url, selector: selector, placement: placement}.to_json})"
|
94
|
+
end
|
95
|
+
|
96
|
+
def close_popover
|
97
|
+
run_javascript "ui.popover.close()"
|
98
|
+
end
|
99
|
+
|
100
|
+
def navigate_to(path)
|
101
|
+
close_modal
|
102
|
+
path = polymorphic_path(path) unless path.is_a?(String)
|
103
|
+
run_javascript "Turbo.visit('#{path}')"
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def turbo_stream
|
109
|
+
@controller.send(:turbo_stream)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Shimmer
|
4
|
+
class SitemapAdapter
|
5
|
+
def write(location, raw_data)
|
6
|
+
SitemapGenerator::FileAdapter.new.write(location, raw_data)
|
7
|
+
ActiveStorage::Blob.service.upload("sitemaps/#{location.path_in_public}", File.open(location.path))
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
data/lib/shimmer/version.rb
CHANGED
data/lib/shimmer.rb
CHANGED
@@ -1,6 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "shimmer/version"
|
4
|
+
require_relative "shimmer/railtie" if defined?(Rails::Railtie)
|
5
|
+
Dir["#{File.expand_path("../lib/shimmer/middlewares", __dir__)}/*"].each { |e| require e }
|
6
|
+
Dir["#{File.expand_path("../lib/shimmer/controllers", __dir__)}/*"].each { |e| require e }
|
7
|
+
Dir["#{File.expand_path("../lib/shimmer/jobs", __dir__)}/*"].each { |e| require e }
|
8
|
+
Dir["#{File.expand_path("../lib/shimmer/utils", __dir__)}/*"].each { |e| require e }
|
4
9
|
|
5
10
|
module Shimmer
|
6
11
|
class Error < StandardError; end
|
data/package.json
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
{
|
2
|
+
"name": "@nerdgeschoss/shimmer",
|
3
|
+
"version": "4.0.0",
|
4
|
+
"description": "Simple application development in Rails",
|
5
|
+
"main": "dist/index.cjs.js",
|
6
|
+
"module": "dist/index.esm.js",
|
7
|
+
"types": "dist/index",
|
8
|
+
"files": [
|
9
|
+
"dist"
|
10
|
+
],
|
11
|
+
"scripts": {
|
12
|
+
"build": "yarn build:js && yarn build:types",
|
13
|
+
"build:js": "NODE_ENV=production rollup -c",
|
14
|
+
"build:types": "tsc --emitDeclarationOnly",
|
15
|
+
"format": "prettier --write \"src/**/*.{ts,css,scss,json,yml}\"",
|
16
|
+
"lint": "yarn lint:types && yarn lint:style",
|
17
|
+
"lint:types": "tsc --noEmit",
|
18
|
+
"lint:style": "eslint src/**/*.ts --max-warnings 0"
|
19
|
+
},
|
20
|
+
"contributors": [
|
21
|
+
"Jens Ravens"
|
22
|
+
],
|
23
|
+
"license": "MIT",
|
24
|
+
"dependencies": {
|
25
|
+
"@hotwired/stimulus": "^3.0.1",
|
26
|
+
"@popperjs/core": "^2.11.0",
|
27
|
+
"@rails/request.js": "^0.0.6"
|
28
|
+
},
|
29
|
+
"devDependencies": {
|
30
|
+
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
31
|
+
"@typescript-eslint/parser": "^5.6.0",
|
32
|
+
"esbuild": "^0.14.2",
|
33
|
+
"eslint": "^8.4.1",
|
34
|
+
"eslint-config-prettier": "^8.3.0",
|
35
|
+
"eslint-plugin-prettier": "^4.0.0",
|
36
|
+
"prettier": "^2.5.1",
|
37
|
+
"rollup": "^2.61.0",
|
38
|
+
"rollup-plugin-cleaner": "^1.0.0",
|
39
|
+
"rollup-plugin-esbuild": "^4.7.2",
|
40
|
+
"typescript": "^4.1.3"
|
41
|
+
},
|
42
|
+
"keywords": [
|
43
|
+
"rails",
|
44
|
+
"form",
|
45
|
+
"modal"
|
46
|
+
],
|
47
|
+
"bugs": {
|
48
|
+
"url": "https://github.com/nerdgeschoss/shimmer/issues"
|
49
|
+
},
|
50
|
+
"homepage": "https://github.com/nerdgeschoss/shimmer#readme"
|
51
|
+
}
|
data/rollup.config.js
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
import esbuild from "rollup-plugin-esbuild";
|
2
|
+
import cleaner from "rollup-plugin-cleaner";
|
3
|
+
import pkg from "./package.json";
|
4
|
+
|
5
|
+
export default {
|
6
|
+
input: "./src/index.ts",
|
7
|
+
external: ["@rails/request.js", "@hotwired/stimulus", "@popperjs/core"],
|
8
|
+
plugins: [
|
9
|
+
cleaner({
|
10
|
+
targets: ["./dist/"],
|
11
|
+
}),
|
12
|
+
esbuild({
|
13
|
+
target: "es6",
|
14
|
+
}),
|
15
|
+
],
|
16
|
+
output: [
|
17
|
+
{
|
18
|
+
file: pkg.main,
|
19
|
+
format: "cjs",
|
20
|
+
},
|
21
|
+
{
|
22
|
+
file: pkg.module,
|
23
|
+
format: "es",
|
24
|
+
},
|
25
|
+
],
|
26
|
+
};
|
data/src/index.ts
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
import type { Application } from "@hotwired/stimulus";
|
2
|
+
import { ModalPresenter } from "./modal";
|
3
|
+
import { PopoverPresenter } from "./popover";
|
4
|
+
import RemoteNavigationController from "./controllers/remote-navigation";
|
5
|
+
import "./touch";
|
6
|
+
|
7
|
+
export { registerServiceWorker } from "./serviceworker";
|
8
|
+
export { currentLocale } from "./locale";
|
9
|
+
|
10
|
+
declare global {
|
11
|
+
interface Window {
|
12
|
+
ui?: {
|
13
|
+
modal: ModalPresenter;
|
14
|
+
popover: PopoverPresenter;
|
15
|
+
};
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
export async function start({
|
20
|
+
application,
|
21
|
+
}: {
|
22
|
+
application: Application;
|
23
|
+
}): Promise<void> {
|
24
|
+
const root = document.createElement("div");
|
25
|
+
root.id = "shimmer";
|
26
|
+
document.body.append(root);
|
27
|
+
application.register("remote-navigation", RemoteNavigationController);
|
28
|
+
window.ui = {
|
29
|
+
modal: new ModalPresenter(),
|
30
|
+
popover: new PopoverPresenter(),
|
31
|
+
};
|
32
|
+
}
|
data/src/locale.ts
ADDED
data/src/modal.ts
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
import { loaded, createElement, nextFrame, getHTML } from "./util";
|
2
|
+
|
3
|
+
export interface ModalOptions {
|
4
|
+
id?: string;
|
5
|
+
url: string;
|
6
|
+
size?: string;
|
7
|
+
close?: boolean;
|
8
|
+
}
|
9
|
+
|
10
|
+
export class ModalPresenter {
|
11
|
+
private modals: Record<string, Modal> = {};
|
12
|
+
|
13
|
+
constructor() {
|
14
|
+
loaded.then(this.prepareBlind);
|
15
|
+
}
|
16
|
+
|
17
|
+
async open(options: ModalOptions): Promise<void> {
|
18
|
+
const id = (options.id = options.id ?? "default-modal");
|
19
|
+
(this.modals[id] = new Modal({ presenter: this, id })).open(options);
|
20
|
+
this.updateBlindStatus();
|
21
|
+
}
|
22
|
+
|
23
|
+
async close({ id }: { id?: string } = {}): Promise<void> {
|
24
|
+
let promise: Promise<unknown> | null = null;
|
25
|
+
if (id) {
|
26
|
+
promise = this.modals[id]?.close();
|
27
|
+
delete this.modals[id];
|
28
|
+
} else {
|
29
|
+
promise = Promise.all(Object.values(this.modals).map((e) => e.close()));
|
30
|
+
this.modals = {};
|
31
|
+
}
|
32
|
+
this.updateBlindStatus();
|
33
|
+
await promise;
|
34
|
+
}
|
35
|
+
|
36
|
+
private updateBlindStatus(): void {
|
37
|
+
const open = Object.keys(this.modals).length > 0;
|
38
|
+
document.body.classList.toggle("modal-open", open);
|
39
|
+
}
|
40
|
+
|
41
|
+
private async prepareBlind(): Promise<void> {
|
42
|
+
createElement(document.body, "modal-blind");
|
43
|
+
}
|
44
|
+
}
|
45
|
+
|
46
|
+
export class Modal {
|
47
|
+
private readonly root: HTMLDivElement;
|
48
|
+
private readonly frame: HTMLDivElement;
|
49
|
+
private readonly closeButton: HTMLDivElement;
|
50
|
+
|
51
|
+
constructor({ presenter, id }: { presenter: ModalPresenter; id: string }) {
|
52
|
+
this.root = createElement(document.body, "modal");
|
53
|
+
const content = createElement(this.root, "modal__content");
|
54
|
+
this.closeButton = createElement(content, "modal__close");
|
55
|
+
this.closeButton.addEventListener("click", () => {
|
56
|
+
presenter.close({ id });
|
57
|
+
});
|
58
|
+
this.frame = createElement(content, "modal__frame");
|
59
|
+
}
|
60
|
+
|
61
|
+
async open({ size, url, close }: ModalOptions): Promise<void> {
|
62
|
+
await nextFrame();
|
63
|
+
this.closeButton.style.display = close ?? true ? "block" : "none";
|
64
|
+
this.root.classList.add("modal--open");
|
65
|
+
this.root.classList.add("modal--loading");
|
66
|
+
this.root.classList.toggle("modal--small", size === "small");
|
67
|
+
this.frame.innerHTML = await getHTML(url);
|
68
|
+
}
|
69
|
+
|
70
|
+
async close(): Promise<void> {
|
71
|
+
this.root.classList.remove("modal--open");
|
72
|
+
}
|
73
|
+
}
|
data/src/popover.ts
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
import { Instance as Popper, createPopper, Placement } from "@popperjs/core";
|
2
|
+
import { createElement, getHTML } from "./util";
|
3
|
+
|
4
|
+
export interface PopoverOptions {
|
5
|
+
id?: string;
|
6
|
+
url: string;
|
7
|
+
selector?: HTMLElement | string;
|
8
|
+
placement?: Placement;
|
9
|
+
}
|
10
|
+
|
11
|
+
export class PopoverPresenter {
|
12
|
+
private popovers: Record<string, Popover> = {};
|
13
|
+
private lastClickedElement?: HTMLElement;
|
14
|
+
|
15
|
+
constructor() {
|
16
|
+
document.addEventListener("click", this.trackElement);
|
17
|
+
}
|
18
|
+
|
19
|
+
async open(options: PopoverOptions): Promise<void> {
|
20
|
+
const id = (options.id = options.id ?? "default-popover");
|
21
|
+
options.selector = options.selector ?? this.lastClickedElement;
|
22
|
+
(this.popovers[id] = new Popover()).open(options);
|
23
|
+
}
|
24
|
+
|
25
|
+
async close({ id }: { id?: string } = {}): Promise<void> {
|
26
|
+
let promise: Promise<unknown> | null = null;
|
27
|
+
if (id) {
|
28
|
+
promise = this.popovers[id]?.close();
|
29
|
+
delete this.popovers[id];
|
30
|
+
} else {
|
31
|
+
promise = Promise.all(Object.values(this.popovers).map((e) => e.close()));
|
32
|
+
this.popovers = {};
|
33
|
+
}
|
34
|
+
await promise;
|
35
|
+
}
|
36
|
+
|
37
|
+
private trackElement = (event: MouseEvent): void => {
|
38
|
+
this.lastClickedElement = event.target as HTMLElement;
|
39
|
+
};
|
40
|
+
}
|
41
|
+
|
42
|
+
export class Popover {
|
43
|
+
private popper?: Popper;
|
44
|
+
private popoverDiv?: HTMLDivElement;
|
45
|
+
|
46
|
+
async open({ url, selector, placement }: PopoverOptions): Promise<void> {
|
47
|
+
const root =
|
48
|
+
typeof selector === "string"
|
49
|
+
? document.querySelector(selector)
|
50
|
+
: selector;
|
51
|
+
if (!root) {
|
52
|
+
return;
|
53
|
+
}
|
54
|
+
const popoverDiv = createElement(document.body, "popover");
|
55
|
+
const arrow = createElement(popoverDiv, "popover__arrow");
|
56
|
+
arrow.setAttribute("data-popper-arrow", "true");
|
57
|
+
const content = createElement(popoverDiv, "popover__content");
|
58
|
+
content.innerHTML = await getHTML(url);
|
59
|
+
this.popper = createPopper(root, popoverDiv, {
|
60
|
+
placement: placement ?? "auto",
|
61
|
+
modifiers: [
|
62
|
+
{
|
63
|
+
name: "offset",
|
64
|
+
options: {
|
65
|
+
offset: [0, 8],
|
66
|
+
},
|
67
|
+
},
|
68
|
+
],
|
69
|
+
});
|
70
|
+
this.popoverDiv = popoverDiv;
|
71
|
+
document.addEventListener("click", this.clickOutside);
|
72
|
+
}
|
73
|
+
|
74
|
+
async close(): Promise<void> {
|
75
|
+
this.popper?.destroy();
|
76
|
+
this.popper = undefined;
|
77
|
+
document.removeEventListener("click", this.clickOutside);
|
78
|
+
this.popoverDiv?.remove();
|
79
|
+
this.popoverDiv = undefined;
|
80
|
+
}
|
81
|
+
|
82
|
+
private clickOutside = (event: MouseEvent): void => {
|
83
|
+
if (this.popoverDiv?.contains(event.target as HTMLElement)) {
|
84
|
+
return;
|
85
|
+
}
|
86
|
+
event.preventDefault();
|
87
|
+
this.close();
|
88
|
+
};
|
89
|
+
}
|
data/src/touch.ts
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
document.addEventListener("turbo:load", () => {
|
2
|
+
if (window.ontouchstart !== undefined) {
|
3
|
+
document.body.classList.add("touch");
|
4
|
+
} else {
|
5
|
+
document.body.classList.add("no-touch");
|
6
|
+
}
|
7
|
+
});
|
8
|
+
|
9
|
+
document.addEventListener("focusin", (event) => {
|
10
|
+
const input = event.target as HTMLElement;
|
11
|
+
if (input.tagName === "INPUT" || input.tagName === "TEXTAREA") {
|
12
|
+
document.body.classList.add("keyboard-visible");
|
13
|
+
}
|
14
|
+
});
|
15
|
+
|
16
|
+
document.addEventListener("focusout", () => {
|
17
|
+
document.body.classList.remove("keyboard-visible");
|
18
|
+
});
|
19
|
+
|
20
|
+
export {};
|
data/src/util.ts
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
import { get } from "@rails/request.js";
|
2
|
+
|
3
|
+
export async function getHTML(url: string): Promise<string> {
|
4
|
+
const response = await get(url, { headers: { "X-Shimmer": "true" } });
|
5
|
+
if (response.ok) {
|
6
|
+
return await response.response.text();
|
7
|
+
}
|
8
|
+
return "";
|
9
|
+
}
|
10
|
+
|
11
|
+
export const loaded: Promise<void> = new Promise((res) => {
|
12
|
+
document.addEventListener("DOMContentLoaded", () => {
|
13
|
+
res();
|
14
|
+
});
|
15
|
+
});
|
16
|
+
|
17
|
+
export async function nextFrame(): Promise<void> {
|
18
|
+
return new Promise((res) => {
|
19
|
+
setTimeout(res, 10);
|
20
|
+
});
|
21
|
+
}
|
22
|
+
|
23
|
+
export function createElement(
|
24
|
+
parent: HTMLElement,
|
25
|
+
className: string
|
26
|
+
): HTMLDivElement {
|
27
|
+
const element = document.createElement("div");
|
28
|
+
element.className = className;
|
29
|
+
parent.append(element);
|
30
|
+
return element;
|
31
|
+
}
|
data/tsconfig.json
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"outDir": "dist",
|
4
|
+
"allowJs": true,
|
5
|
+
"esModuleInterop": true,
|
6
|
+
"experimentalDecorators": true,
|
7
|
+
"forceConsistentCasingInFileNames": true,
|
8
|
+
"lib": ["dom", "esnext"],
|
9
|
+
"module": "commonjs",
|
10
|
+
"moduleResolution": "node",
|
11
|
+
"noImplicitAny": false,
|
12
|
+
"noImplicitReturns": true,
|
13
|
+
"noImplicitThis": true,
|
14
|
+
"noUnusedLocals": true,
|
15
|
+
"noUnusedParameters": true,
|
16
|
+
"rootDir": "src",
|
17
|
+
"skipLibCheck": true,
|
18
|
+
"sourceMap": true,
|
19
|
+
"strict": false,
|
20
|
+
"strictNullChecks": true,
|
21
|
+
"suppressImplicitAnyIndexErrors": true,
|
22
|
+
"target": "es6",
|
23
|
+
"isolatedModules": true,
|
24
|
+
"declaration": true
|
25
|
+
},
|
26
|
+
"exclude": ["node_modules", "dist"],
|
27
|
+
"files": ["src/index.ts"]
|
28
|
+
}
|
data/typings.d.ts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
declare module "@rails/request.js";
|