shimmer 0.0.1 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shimmer
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.7"
5
5
  end
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
+ };
@@ -0,0 +1,9 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ connect(): void {
5
+ const script = (this.element as HTMLDivElement).innerText;
6
+ (0, eval)(script);
7
+ this.element.remove();
8
+ }
9
+ }
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
@@ -0,0 +1,3 @@
1
+ export function currentLocale(): string {
2
+ return (document.documentElement.lang || "en") as string;
3
+ }
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
+ }
@@ -0,0 +1,7 @@
1
+ export async function registerServiceWorker(): Promise<void> {
2
+ if (navigator.serviceWorker) {
3
+ await navigator.serviceWorker.register("/serviceworker.js", {
4
+ scope: "./",
5
+ });
6
+ }
7
+ }
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";