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.
@@ -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";