katalyst-kpop 2.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bfe556211c8cb27976e75cf940c7ae065fc6fbfb31f8a9fb2c78ec6e3d36db4d
4
+ data.tar.gz: 7014e09c2697ff7e0d0093f67068b1583a21c8bdb165222df6b990e63869eac0
5
+ SHA512:
6
+ metadata.gz: 7affe4e861d299c11391520a71adf5d7a9fa4e1af699d7bd9938b0c784784c531992fd68d7d8ea14c58a974b2f6a52ff6e021bcab5811874730e006b1d7d31e9
7
+ data.tar.gz: d087f450b624e8dc6e4c2426672eb697e52f6e5fe3bbac402f2b71458d0c35ce8f956a60b6c6446f9a6bce4100fbb33865dd4af8550ce12fe0342540a59add1d
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # kpop
2
+
3
+ Modals driven by `@hotwire/turbo` frame navigation.
4
+
5
+ kpop requires `@hotwire/turbo` and `@hotwire/stimulus` to be installed and configured correctly to be used.
6
+
7
+ ## Installation
8
+
9
+ Install gem
10
+ ```bash
11
+ # Gemfile
12
+
13
+ $ bundle add "katalyst-kpop"
14
+ ```
15
+
16
+ kpop supports installation of javascript dependencies with either import maps or yarn.
17
+
18
+ ### Stimulus controllers
19
+
20
+ If you are using asset pipeline and import maps then the stimulus controllers
21
+ for modals and scrim will be automatically available without configuration.
22
+
23
+ ### Stylesheets
24
+
25
+ Import stylesheets through using SASS using asset pipeline:
26
+
27
+ ```scss
28
+ // app/assets/stylesheets/application.scss
29
+
30
+ @use "katalyst/kpop";
31
+ ```
32
+
33
+ You can also load a precompiled version from the gem directly:
34
+
35
+ ```erb
36
+ <%# app/views/layouts/application.html.erb #>
37
+
38
+ <%= stylesheet_link_tag "katalyst/kpop" %>
39
+ ```
40
+
41
+ ### Yarn
42
+
43
+ If you are not using import maps, you can add the yarn package to your project:
44
+
45
+ ```bash
46
+ $ yarn add "@katalyst-interactive/kpop"
47
+ ```
48
+
49
+ ### Import kpop styles
50
+ ```css
51
+ /* application.scss */
52
+
53
+ @import "~@katalyst-interactive/kpop";
54
+ ```
55
+
56
+ ### Import kpop stimulus controllers
57
+ ```js
58
+ /* application.js */
59
+ import kpop from "@katalyst-interactive/kpop"
60
+ application.load(kpop)
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ kpop provides helpers to add a basic scrim and modal target frame. These should be placed inside the body:
66
+ ```html
67
+ <body>
68
+ <%= scrim_tag %>
69
+ <%= kpop_frame_tag do %>
70
+ <%= yield :kpop %>
71
+ <% end %>
72
+ </body>
73
+ ```
74
+
75
+ ### Show a modal
76
+
77
+ To show a modal you need to add content to the kpop turbo frame. You can do this in several ways:
78
+ 1. Use `content_for :kpop` in an HTML response to inject content into the kpop frame (see `yield :kpop` above)
79
+ 2. Use `layout "kpop"` in your controller to wrap your turbo response in a kpop frame
80
+
81
+ You can generate a link that will cause a modal to show using the `kpop_link_to` helper.
82
+
83
+ `kpop_link_to`'s are similar to a `link_to` in rails, but it will navigate to the given URL within the modal turbo
84
+ frame. The targeted action will need to generate content in a `kpop_frame_tag`, e.g. using `layout "kpop"`.
85
+
86
+ ```html
87
+ <!-- app/views/homepage/index.html.erb -->
88
+ <%= modal_link_to "click to open modal", modal_path("example") %>
89
+ ```
90
+
91
+ ```html
92
+ <!-- app/views/modals/show.html.erb -->
93
+ <%= render_kpop(title: "Modal title") do %>
94
+ Modal content
95
+ <% end %>
96
+ ```
97
+
98
+ Note that, because kpop modals render in a turbo frame, if you want to navigate the parent frame you will need to use
99
+ `target: "_top"` on your links and forms.
100
+
101
+ ## Development
102
+
103
+ Releases need to be distributed to rubygems.org and npmjs.org. To do this, you need to have accounts with both providers
104
+ and be added as a collaborator to the kpop gem and npm packages.
105
+
106
+ 1. Update the version in `package.json` and `lib/katalyst/kpop/version.rb`
107
+ 2. Ensure that `rake` passes (format and tests)
108
+ 3. Tag a release and push to rubygems.org by running `rake release`
109
+ 4. Push the new version to npmjs.org by running `yarn publish`
110
+
111
+ ## License
112
+
113
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,117 @@
1
+ .kpop-container {
2
+ display: none;
3
+ position: fixed;
4
+ left: 0;
5
+ top: 0;
6
+ right: 0;
7
+ bottom: 0;
8
+ justify-content: center;
9
+ align-items: center;
10
+ z-index: 1000;
11
+ pointer-events: none;
12
+ }
13
+ .kpop-container > * {
14
+ pointer-events: auto;
15
+ }
16
+
17
+ .kpop-modal {
18
+ position: relative;
19
+ overflow: hidden;
20
+ display: grid;
21
+ grid-template-areas: "title-bar" "header" "content" "footer";
22
+ grid-template-rows: auto auto 1fr auto;
23
+ border: 1px solid black;
24
+ border-radius: 0.5rem;
25
+ background-color: white;
26
+ min-width: 35rem;
27
+ max-width: 52rem;
28
+ min-height: 0;
29
+ max-height: 80vh;
30
+ }
31
+ .kpop-modal .kpop-title-bar {
32
+ grid-area: title-bar;
33
+ display: flex;
34
+ background: #344055;
35
+ color: white;
36
+ }
37
+ .kpop-modal .kpop-title-bar .kpop-title {
38
+ padding: 1rem 1.5rem;
39
+ flex-grow: 1;
40
+ }
41
+ .kpop-modal .kpop-title-bar .kpop-close {
42
+ background: none;
43
+ border: none;
44
+ color: white;
45
+ cursor: pointer;
46
+ display: block;
47
+ font-size: 2rem;
48
+ font-weight: bold;
49
+ padding: 0 0.75rem;
50
+ text-decoration: none;
51
+ }
52
+ .kpop-modal .kpop-header {
53
+ grid-area: header;
54
+ }
55
+ .kpop-modal .kpop-content {
56
+ grid-area: content;
57
+ display: flex;
58
+ flex-direction: column;
59
+ overflow: auto;
60
+ }
61
+ .kpop-modal .kpop-footer {
62
+ grid-area: footer;
63
+ background: white;
64
+ border-top: 1px solid black;
65
+ padding: 1rem 1.5rem;
66
+ }
67
+ .kpop-modal .kpop-buttons {
68
+ display: flex;
69
+ gap: 0.5rem;
70
+ justify-content: space-between;
71
+ }
72
+
73
+ .kpop-modal.iframe .kpop-content {
74
+ overflow: unset;
75
+ }
76
+ .kpop-modal.iframe iframe {
77
+ height: 80vh;
78
+ width: 52rem;
79
+ flex-grow: 1;
80
+ overflow: scroll;
81
+ }
82
+
83
+ @media (max-width: 600px), (max-height: 600px) {
84
+ .kpop-modal {
85
+ max-width: unset;
86
+ min-width: unset;
87
+ width: 100%;
88
+ height: 100%;
89
+ max-height: 100vh;
90
+ border-radius: 0;
91
+ border: none;
92
+ }
93
+ .kpop-modal.iframe iframe {
94
+ width: 100%;
95
+ height: 100%;
96
+ }
97
+ .kpop-buttons {
98
+ flex-direction: column-reverse;
99
+ text-align: center;
100
+ }
101
+ }
102
+ .scrim {
103
+ position: fixed;
104
+ top: 0;
105
+ bottom: 0;
106
+ left: 0;
107
+ right: 0;
108
+ background: rgba(0, 0, 0, 0.6);
109
+ z-index: -1;
110
+ transition: opacity 0.2s ease-in-out, z-index 0.2s step-end;
111
+ opacity: 0;
112
+ }
113
+
114
+ .scrim[data-scrim-open-value=true] {
115
+ opacity: 1;
116
+ transition: opacity 0.2s ease-in-out, z-index 0.2s step-start;
117
+ }
@@ -0,0 +1 @@
1
+ function e(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}function t(e){return e.charAt(0).toUpperCase()+e.slice(1)}function r(e,t){const r=s(e);return Array.from(r.reduce(((e,r)=>(function(e,t){const r=e[t];return Array.isArray(r)?r:[]}(r,t).forEach((t=>e.add(t))),e)),new Set))}function n(e,t){return s(e).reduce(((e,r)=>(e.push(...function(e,t){const r=e[t];return r?Object.keys(r).map((e=>[e,r[e]])):[]}(r,t)),e)),[])}function s(e){const t=[];for(;e;)t.push(e),e=Object.getPrototypeOf(e);return t.reverse()}function i(e){return e.reduce(((e,[t,r])=>Object.assign(Object.assign({},e),{[t]:r})),{})}function o([t,r],n){return function(t){const r=`${s=t.token,s.replace(/([A-Z])/g,((e,t)=>`-${t.toLowerCase()}`))}-value`,n=function(e){const t=function(e){const t=a(e.typeObject.type);if(!t)return;const r=c(e.typeObject.default);if(t!==r){const n=e.controller?`${e.controller}.${e.token}`:e.token;throw new Error(`The specified default value for the Stimulus Value "${n}" must match the defined type "${t}". The provided default value of "${e.typeObject.default}" is of type "${r}".`)}return t}({controller:e.controller,token:e.token,typeObject:e.typeDefinition}),r=c(e.typeDefinition),n=a(e.typeDefinition),s=t||r||n;if(s)return s;const i=e.controller?`${e.controller}.${e.typeDefinition}`:e.token;throw new Error(`Unknown value type "${i}" for "${e.token}" value`)}(t);var s;return{type:n,key:r,name:e(r),get defaultValue(){return function(e){const t=a(e);if(t)return u[t];const r=e.default;return void 0!==r?r:e}(t.typeDefinition)},get hasCustomDefaultValue(){return void 0!==c(t.typeDefinition)},reader:l[n],writer:d[n]||d.default}}({controller:n,token:t,typeDefinition:r})}function a(e){switch(e){case Array:return"array";case Boolean:return"boolean";case Number:return"number";case Object:return"object";case String:return"string"}}function c(e){switch(typeof e){case"boolean":return"boolean";case"number":return"number";case"string":return"string"}return Array.isArray(e)?"array":"[object Object]"===Object.prototype.toString.call(e)?"object":void 0}(()=>{function e(e){function t(){return Reflect.construct(e,arguments,new.target)}return t.prototype=Object.create(e.prototype,{constructor:{value:t}}),Reflect.setPrototypeOf(t,e),t}try{return function(){const t=e((function(){this.a.call(this)}));t.prototype.a=function(){},new t}(),e}catch(e){return e=>class extends e{}}})(),Object.assign(Object.assign({enter:"Enter",tab:"Tab",esc:"Escape",space:" ",up:"ArrowUp",down:"ArrowDown",left:"ArrowLeft",right:"ArrowRight",home:"Home",end:"End"},i("abcdefghijklmnopqrstuvwxyz".split("").map((e=>[e,e])))),i("0123456789".split("").map((e=>[e,e]))));const u={get array(){return[]},boolean:!1,number:0,get object(){return{}},string:""},l={array(e){const t=JSON.parse(e);if(!Array.isArray(t))throw new TypeError(`expected value of type "array" but instead got value "${e}" of type "${c(t)}"`);return t},boolean:e=>!("0"==e||"false"==String(e).toLowerCase()),number:e=>Number(e),object(e){const t=JSON.parse(e);if(null===t||"object"!=typeof t||Array.isArray(t))throw new TypeError(`expected value of type "object" but instead got value "${e}" of type "${c(t)}"`);return t},string:e=>e},d={default:function(e){return`${e}`},array:h,object:h};function h(e){return JSON.stringify(e)}class p{constructor(e){this.context=e}static get shouldLoad(){return!0}static afterLoad(e,t){}get application(){return this.context.application}get scope(){return this.context.scope}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get targets(){return this.scope.targets}get outlets(){return this.scope.outlets}get classes(){return this.scope.classes}get data(){return this.scope.data}initialize(){}connect(){}disconnect(){}dispatch(e,{target:t=this.element,detail:r={},prefix:n=this.identifier,bubbles:s=!0,cancelable:i=!0}={}){const o=new CustomEvent(n?`${n}:${e}`:e,{detail:r,bubbles:s,cancelable:i});return t.dispatchEvent(o),o}}p.blessings=[function(e){return r(e,"classes").reduce(((e,r)=>{return Object.assign(e,{[`${n=r}Class`]:{get(){const{classes:e}=this;if(e.has(n))return e.get(n);{const t=e.getAttributeName(n);throw new Error(`Missing attribute "${t}"`)}}},[`${n}Classes`]:{get(){return this.classes.getAll(n)}},[`has${t(n)}Class`]:{get(){return this.classes.has(n)}}});var n}),{})},function(e){return r(e,"targets").reduce(((e,r)=>{return Object.assign(e,{[`${n=r}Target`]:{get(){const e=this.targets.find(n);if(e)return e;throw new Error(`Missing target element "${n}" for "${this.identifier}" controller`)}},[`${n}Targets`]:{get(){return this.targets.findAll(n)}},[`has${t(n)}Target`]:{get(){return this.targets.has(n)}}});var n}),{})},function(e){const r=n(e,"values"),s={valueDescriptorMap:{get(){return r.reduce(((e,t)=>{const r=o(t,this.identifier),n=this.data.getAttributeNameForKey(r.key);return Object.assign(e,{[n]:r})}),{})}}};return r.reduce(((e,r)=>Object.assign(e,function(e,r){const n=o(e,r),{key:s,name:i,reader:a,writer:c}=n;return{[i]:{get(){const e=this.data.get(s);return null!==e?a(e):n.defaultValue},set(e){void 0===e?this.data.delete(s):this.data.set(s,c(e))}},[`has${t(i)}`]:{get(){return this.data.has(s)||n.hasCustomDefaultValue}}}}(r))),s)},function(n){return r(n,"outlets").reduce(((r,n)=>Object.assign(r,function(r){const n=(s=r,e(s.replace(/--/g,"-").replace(/__/g,"_")));var s;return{[`${n}Outlet`]:{get(){const e=this.outlets.find(r);if(e){const t=this.application.getControllerForElementAndIdentifier(e,r);if(t)return t;throw new Error(`Missing "data-controller=${r}" attribute on outlet element for "${this.identifier}" controller`)}throw new Error(`Missing outlet element "${r}" for "${this.identifier}" controller`)}},[`${n}Outlets`]:{get(){const e=this.outlets.findAll(r);return e.length>0?e.map((e=>{const t=this.application.getControllerForElementAndIdentifier(e,r);if(t)return t;console.warn(`The provided outlet element is missing the outlet controller "${r}" for "${this.identifier}"`,e)})).filter((e=>e)):[]}},[`${n}OutletElement`]:{get(){const e=this.outlets.find(r);if(e)return e;throw new Error(`Missing outlet element "${r}" for "${this.identifier}" controller`)}},[`${n}OutletElements`]:{get(){return this.outlets.findAll(r)}},[`has${t(n)}Outlet`]:{get(){return this.outlets.has(r)}}}}(n))),{})}],p.targets=[],p.outlets=[],p.values={};class f extends p{static values={open:Boolean,captive:Boolean,zIndex:Number};static showScrim({dismiss:e=!0,zIndex:t,top:r}={}){return window.dispatchEvent(new CustomEvent("scrim:request:show",{cancelable:!0,detail:{captive:!e,zIndex:t,top:r}}))}static hideScrim(){return window.dispatchEvent(new CustomEvent("scrim:request:hide",{cancelable:!0}))}connect(){this.defaultZIndexValue=this.zIndexValue,this.defaultCaptiveValue=this.captiveValue}show(e){if(this.openValue&&this.hide(e),this.openValue)return;this.openValue=!0;if(this.dispatch("show",{bubbles:!0,cancelable:!0}).defaultPrevented)return this.openValue=!1,void e.preventDefault();this.#e(e.detail)}hide(e){if(!this.openValue)return;this.openValue=!1;if(this.dispatch("hide",{bubbles:!0,cancelable:!0}).defaultPrevented)return this.openValue=!0,void e.preventDefault();this.#t()}dismiss(e){this.captiveValue||this.hide(e)}escape(e){"Escape"!==e.key||this.captiveValue||e.defaultPrevented||this.hide(e)}disconnect(){super.disconnect()}#e({captive:e=this.defaultCaptiveValue,zIndex:t=this.defaultZIndexValue,top:r=window.scrollY}){this.captiveValue=e,this.zIndexValue=t,this.scrollY=r,this.previousPosition=document.body.style.position,this.previousTop=document.body.style.top,this.element.style.zIndex=this.zIndexValue,document.body.style.top=`-${r}px`,document.body.style.position="fixed"}#t(){this.captiveValue=this.defaultCaptiveValue,this.zIndexValue=this.defaultZIndexValue,g(this.element,"z-index",null),g(document.body,"position",null),g(document.body,"top",null),window.scrollTo(0,this.scrollY),delete this.scrollY,delete this.previousPosition,delete this.previousTop}}function g(e,t,r){r?e.style.setProperty(t,r):e.style.removeProperty(t)}class b extends p{static targets=["content","closeButton"];static values={open:Boolean};contentTargetConnected(){this.openValue||(f.showScrim({dismiss:this.hasCloseButtonTarget})?this.openValue=!0:this.#r())}contentTargetDisconnected(){this.hasContentTarget||(this.openValue=!1,f.hideScrim())}openValueChanged(e){this.element.style.display=e?"flex":"none"}dismiss(){if(!this.hasContentTarget||!this.openValue)return;const e=this.contentTarget.dataset.dismissUrl,t=this.contentTarget.dataset.dismissAction;e&&("replace"===t?!function(e,t){try{return`${new URL(e)}`==`${new URL(t,location.href)}`}catch{return!1}}(document.referrer,e)?history.replaceState({},"",e):history.back():window.location.href=e),this.#r()}#r(){this.element.removeAttribute("src"),this.element.innerHTML=""}}const y=[{identifier:"kpop",controllerConstructor:b},{identifier:"scrim",controllerConstructor:f}];export{b as KpopController,f as ScrimController,y as default};
@@ -0,0 +1 @@
1
+ //= link_tree ../javascripts/controllers
@@ -0,0 +1,69 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import ScrimController from "./scrim_controller";
3
+
4
+ export default class KpopController extends Controller {
5
+ static targets = ["content", "closeButton"];
6
+ static values = {
7
+ open: Boolean,
8
+ };
9
+
10
+ contentTargetConnected() {
11
+ // When switching modals a target may connect while scrim is already open
12
+ if (this.openValue) return;
13
+
14
+ if (ScrimController.showScrim({ dismiss: this.hasCloseButtonTarget })) {
15
+ this.openValue = true;
16
+ } else {
17
+ this.#clear();
18
+ }
19
+ }
20
+
21
+ contentTargetDisconnected() {
22
+ // When switching modals there may still be content to show
23
+ if (this.hasContentTarget) return;
24
+
25
+ this.openValue = false;
26
+ ScrimController.hideScrim();
27
+ }
28
+
29
+ openValueChanged(open) {
30
+ this.element.style.display = open ? "flex" : "none";
31
+ }
32
+
33
+ dismiss() {
34
+ if (!this.hasContentTarget || !this.openValue) return;
35
+
36
+ const dismissUrl = this.contentTarget.dataset.dismissUrl;
37
+ const dismissAction = this.contentTarget.dataset.dismissAction;
38
+
39
+ if (dismissUrl) {
40
+ if (dismissAction === "replace") {
41
+ if (isSameUrl(document.referrer, dismissUrl)) {
42
+ // if we came from the same page, send the user back
43
+ history.back();
44
+ } else {
45
+ // if we came from a different page, dismiss the modal and replace url
46
+ history.replaceState({}, "", dismissUrl);
47
+ }
48
+ } else {
49
+ // default, send the user on to the specified URL
50
+ window.location.href = dismissUrl;
51
+ }
52
+ }
53
+
54
+ this.#clear();
55
+ }
56
+
57
+ #clear() {
58
+ this.element.removeAttribute("src");
59
+ this.element.innerHTML = "";
60
+ }
61
+ }
62
+
63
+ function isSameUrl(previous, next) {
64
+ try {
65
+ return `${new URL(previous)}` === `${new URL(next, location.href)}`;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -0,0 +1,162 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const DEBUG = false;
4
+
5
+ /**
6
+ * Scrim controller wraps an element that creates a whole page layer.
7
+ * It is intended to be used behind a modal or nav drawer.
8
+ *
9
+ * If the Scrim element receives a click event, it automatically triggers "scrim:hide".
10
+ *
11
+ * You can show and hide the scrim programmatically by sending "scrim:request:show" and "scrim:request:hide" events to
12
+ * the window or by calling the provided methods.
13
+ *
14
+ * If you need to respond to the scrim showing or hiding you should subscribe to "scrim:show" and "scrim:hide".
15
+ */
16
+ export default class ScrimController extends Controller {
17
+ static values = {
18
+ open: Boolean,
19
+ captive: Boolean,
20
+ zIndex: Number,
21
+ };
22
+
23
+ /**
24
+ * Show the scrim element. Returns true if successful.
25
+ */
26
+ static showScrim({
27
+ dismiss = true,
28
+ zIndex = undefined,
29
+ top = undefined,
30
+ } = {}) {
31
+ return window.dispatchEvent(
32
+ new CustomEvent("scrim:request:show", {
33
+ cancelable: true,
34
+ detail: { captive: !dismiss, zIndex: zIndex, top: top },
35
+ })
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Hide the scrim element. Returns true if successful.
41
+ */
42
+ static hideScrim() {
43
+ return window.dispatchEvent(
44
+ new CustomEvent("scrim:request:hide", { cancelable: true })
45
+ );
46
+ }
47
+
48
+ connect() {
49
+ this.defaultZIndexValue = this.zIndexValue;
50
+ this.defaultCaptiveValue = this.captiveValue;
51
+ }
52
+
53
+ show(request) {
54
+ if (DEBUG) console.debug("request show scrim");
55
+
56
+ // hide the scrim before opening the new one if it's already open
57
+ if (this.openValue) this.hide(request);
58
+
59
+ // if the scrim is still open, abort
60
+ if (this.openValue) return;
61
+
62
+ // update internal state to break event cycles
63
+ this.openValue = true;
64
+
65
+ // notify listeners of pending request
66
+ const event = this.dispatch("show", { bubbles: true, cancelable: true });
67
+
68
+ // if notification was cancelled, update request and abort
69
+ if (event.defaultPrevented) {
70
+ this.openValue = false;
71
+ request.preventDefault();
72
+ return;
73
+ }
74
+
75
+ if (DEBUG) console.debug("show scrim");
76
+
77
+ // perform show updates
78
+ this.#show(request.detail);
79
+ }
80
+
81
+ hide(request) {
82
+ if (!this.openValue) return;
83
+
84
+ if (DEBUG) console.debug("request hide scrim");
85
+
86
+ // update internal state to break event cycles
87
+ this.openValue = false;
88
+
89
+ // notify listeners of pending request
90
+ const event = this.dispatch("hide", { bubbles: true, cancelable: true });
91
+
92
+ // if notification was cancelled, update request and abort
93
+ if (event.defaultPrevented) {
94
+ this.openValue = true;
95
+ request.preventDefault();
96
+ return;
97
+ }
98
+
99
+ if (DEBUG) console.debug("hide scrim");
100
+
101
+ // update state, perform style updates
102
+ this.#hide();
103
+ }
104
+
105
+ dismiss(event) {
106
+ if (!this.captiveValue) this.hide(event);
107
+ }
108
+
109
+ escape(event) {
110
+ if (event.key === "Escape" && !this.captiveValue && !event.defaultPrevented)
111
+ this.hide(event);
112
+ }
113
+
114
+ disconnect() {
115
+ super.disconnect();
116
+ }
117
+
118
+ /**
119
+ * Clips body to viewport size and sets the z-index
120
+ */
121
+ #show({
122
+ captive = this.defaultCaptiveValue,
123
+ zIndex = this.defaultZIndexValue,
124
+ top = window.scrollY,
125
+ }) {
126
+ this.captiveValue = captive;
127
+ this.zIndexValue = zIndex;
128
+ this.scrollY = top;
129
+
130
+ this.previousPosition = document.body.style.position;
131
+ this.previousTop = document.body.style.top;
132
+
133
+ this.element.style.zIndex = this.zIndexValue;
134
+ document.body.style.top = `-${top}px`;
135
+ document.body.style.position = "fixed";
136
+ }
137
+
138
+ /**
139
+ * Unclips body from viewport size and unsets the z-index
140
+ */
141
+ #hide() {
142
+ this.captiveValue = this.defaultCaptiveValue;
143
+ this.zIndexValue = this.defaultZIndexValue;
144
+
145
+ resetStyle(this.element, "z-index", null);
146
+ resetStyle(document.body, "position", null);
147
+ resetStyle(document.body, "top", null);
148
+ window.scrollTo(0, this.scrollY);
149
+
150
+ delete this.scrollY;
151
+ delete this.previousPosition;
152
+ delete this.previousTop;
153
+ }
154
+ }
155
+
156
+ function resetStyle(element, property, previousValue) {
157
+ if (previousValue) {
158
+ element.style.setProperty(property, previousValue);
159
+ } else {
160
+ element.style.removeProperty(property);
161
+ }
162
+ }
@@ -0,0 +1,9 @@
1
+ import KpopController from "../controllers/kpop_controller";
2
+ import ScrimController from "../controllers/scrim_controller";
3
+
4
+ const Definitions = [
5
+ { identifier: "kpop", controllerConstructor: KpopController },
6
+ { identifier: "scrim", controllerConstructor: ScrimController },
7
+ ];
8
+
9
+ export { Definitions as default, KpopController, ScrimController };
@@ -0,0 +1,2 @@
1
+ @use "kpop";
2
+ @use "scrim";
@@ -0,0 +1,133 @@
1
+ $title-background-color: #344055 !default;
2
+ $title-text-color: white !default;
3
+ $min-width: 35rem !default;
4
+ $max-width: 52rem !default;
5
+ $min-height: 0 !default;
6
+ $max-height: 80vh !default;
7
+ $default-padding: 1rem 1.5rem !default;
8
+
9
+ .kpop-container {
10
+ display: none;
11
+
12
+ position: fixed;
13
+ left: 0;
14
+ top: 0;
15
+ right: 0;
16
+ bottom: 0;
17
+
18
+ justify-content: center;
19
+ align-items: center;
20
+ z-index: 1000;
21
+ pointer-events: none;
22
+
23
+ > * {
24
+ pointer-events: auto;
25
+ }
26
+ }
27
+
28
+ .kpop-modal {
29
+ position: relative;
30
+ overflow: hidden;
31
+
32
+ display: grid;
33
+ grid-template-areas:
34
+ "title-bar"
35
+ "header"
36
+ "content"
37
+ "footer";
38
+ grid-template-rows: auto auto 1fr auto;
39
+
40
+ border: 1px solid black;
41
+ border-radius: 0.5rem;
42
+ background-color: white;
43
+
44
+ min-width: $min-width;
45
+ max-width: $max-width;
46
+ min-height: $min-height;
47
+ max-height: $max-height;
48
+
49
+ .kpop-title-bar {
50
+ grid-area: title-bar;
51
+ display: flex;
52
+ background: $title-background-color;
53
+ color: $title-text-color;
54
+
55
+ .kpop-title {
56
+ padding: $default-padding;
57
+ flex-grow: 1;
58
+ }
59
+
60
+ .kpop-close {
61
+ background: none;
62
+ border: none;
63
+ color: $title-text-color;
64
+ cursor: pointer;
65
+ display: block;
66
+ font-size: 2rem;
67
+ font-weight: bold;
68
+ padding: 0 0.75rem;
69
+ text-decoration: none;
70
+ }
71
+ }
72
+
73
+ .kpop-header {
74
+ grid-area: header;
75
+ }
76
+
77
+ .kpop-content {
78
+ grid-area: content;
79
+ display: flex;
80
+ flex-direction: column;
81
+ overflow: auto;
82
+ }
83
+
84
+ .kpop-footer {
85
+ grid-area: footer;
86
+ background: white;
87
+ border-top: 1px solid black;
88
+ padding: $default-padding;
89
+ }
90
+
91
+ .kpop-buttons {
92
+ display: flex;
93
+ gap: 0.5rem;
94
+ justify-content: space-between;
95
+ }
96
+ }
97
+
98
+ .kpop-modal.iframe {
99
+ .kpop-content {
100
+ overflow: unset;
101
+ }
102
+
103
+ iframe {
104
+ height: $max-height;
105
+ width: $max-width;
106
+ flex-grow: 1;
107
+ overflow: scroll;
108
+ }
109
+ }
110
+
111
+ @media (max-width: 600px), (max-height: 600px) {
112
+ .kpop-modal {
113
+ max-width: unset;
114
+ min-width: unset;
115
+ width: 100%;
116
+ height: 100%;
117
+ max-height: 100vh;
118
+ border-radius: 0;
119
+ border: none;
120
+ }
121
+
122
+ .kpop-modal.iframe {
123
+ iframe {
124
+ width: 100%;
125
+ height: 100%;
126
+ }
127
+ }
128
+
129
+ .kpop-buttons {
130
+ flex-direction: column-reverse;
131
+ text-align: center;
132
+ }
133
+ }
@@ -0,0 +1,16 @@
1
+ .scrim {
2
+ position: fixed;
3
+ top: 0;
4
+ bottom: 0;
5
+ left: 0;
6
+ right: 0;
7
+ background: rgba(0, 0, 0, 0.6);
8
+ z-index: -1;
9
+ transition: opacity 0.2s ease-in-out, z-index 0.2s step-end;
10
+ opacity: 0;
11
+ }
12
+
13
+ .scrim[data-scrim-open-value="true"] {
14
+ opacity: 1;
15
+ transition: opacity 0.2s ease-in-out, z-index 0.2s step-start;
16
+ }
@@ -0,0 +1 @@
1
+ @use "kpop/index";
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Rails/HelperInstanceVariable
4
+ module Kpop
5
+ class Modal
6
+ delegate_missing_to :@context
7
+
8
+ def initialize(context)
9
+ @context = context
10
+ end
11
+
12
+ def render(options = {})
13
+ dom_class = options.delete(:class)
14
+
15
+ # Generate a title bar. This can be overridden by calling title_bar again.
16
+ title_bar(options) unless options.fetch(:title, "").nil?
17
+
18
+ # Render block. This may have side-effect writes to header/content/footer
19
+ # etc. If @content is set then this value will be ignored.
20
+ content = capture do
21
+ yield self
22
+ end
23
+
24
+ tag.div(class: class_names("kpop-modal", dom_class),
25
+ data: kpop_data_options(options),
26
+ **options) do
27
+ concat @title_bar
28
+ concat @header if @header.present?
29
+ concat @content.presence || tag.div(content, class: "kpop-content")
30
+ concat @footer if @footer.present?
31
+ end
32
+ end
33
+
34
+ # Generates a sticky title bar for the modal. Content should not be too long
35
+ # as the bar does not provide wrapping.
36
+ def title_bar(options = {}, &block)
37
+ title = options.delete(:title)
38
+ captive = options.delete(:captive)
39
+ @title_bar = tag.div(class: "kpop-title-bar", **options) do
40
+ concat(tag.span(class: "kpop-title") do
41
+ concat(block ? (yield self) : title)
42
+ end)
43
+ concat(close_icon) unless captive
44
+ end
45
+ nil
46
+ end
47
+
48
+ # Generates sticky header content for the top of the modal. Content is not
49
+ # padded, if you want padding you should provide a padding class.
50
+ def header(**options, &block)
51
+ modal_content(:header, **options, &block)
52
+ end
53
+
54
+ # Generates content for the modal. Content is not padded, if you want
55
+ # padding you should provide a padding class.
56
+ def content(**options, &block)
57
+ modal_content(:content, **options, &block)
58
+ end
59
+
60
+ # Generates a sticky footer element at the bottom of the modal.
61
+ # Footer is padded and contents are assumed to be buttons.
62
+ def footer(**options, &block)
63
+ modal_content(:footer, **options, &block)
64
+ end
65
+
66
+ def close_icon
67
+ tag.button(
68
+ "×",
69
+ class: "kpop-close",
70
+ data: {
71
+ kpop_target: "closeButton",
72
+ action: "click->kpop#dismiss:prevent",
73
+ },
74
+ )
75
+ end
76
+
77
+ private
78
+
79
+ def kpop_data_options(options)
80
+ data = options.delete(:data) || {}
81
+ data.reverse_merge(
82
+ kpop_target: "content",
83
+ dismiss_action: options.delete(:dismiss_action),
84
+ dismiss_url: options.delete(:dismiss_url),
85
+ )
86
+ end
87
+
88
+ def class_for(name, options)
89
+ class_names("kpop-#{name}", options.delete(:class))
90
+ end
91
+
92
+ def modal_content(name, **options, &block)
93
+ instance_variable_set("@#{name}", tag.div(class: class_for(name, options), **options, &block))
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ # rubocop:enable Rails/HelperInstanceVariable
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KpopHelper
4
+ def render_kpop(options = {}, &block)
5
+ Kpop::Modal.new(self).render(options, &block)
6
+ end
7
+
8
+ def kpop_frame_tag(&block)
9
+ turbo_frame_tag "kpop",
10
+ class: "kpop-container",
11
+ data: { controller: "kpop", action: "scrim:hide@window->kpop#dismiss" } do
12
+ capture(&block) if block
13
+ end
14
+ end
15
+
16
+ def kpop_link_to(name = nil, options = nil, html_options = nil, &block)
17
+ default_html_options = {
18
+ data: { turbo: true, turbo_frame: "kpop" },
19
+ }
20
+ if block
21
+ # Param[name] is the path for the link
22
+ link_to(name, default_html_options.deep_merge(options || {}), &block)
23
+ else
24
+ link_to(name, options, default_html_options.deep_merge(html_options || {}))
25
+ end
26
+ end
27
+
28
+ def kpop_button_to(name = nil, options = nil, html_options = nil, &block)
29
+ default_html_options = {
30
+ form: { data: { turbo: true, turbo_frame: "kpop" } },
31
+ }
32
+ button_to(name, options, default_html_options.deep_merge(html_options || {}), &block)
33
+ end
34
+
35
+ def kpop_button_close(content = nil, **options, &block)
36
+ content = block ? capture(yield) : content
37
+ tag.button content, data: { action: "click->kpop#dismiss:prevent" }, **options
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScrimHelper
4
+ def scrim_tag(z_index: 40)
5
+ tag.div(class: "scrim", data: { controller: "scrim", scrim_z_index_value: z_index, action: <<~ACTIONS })
6
+ click->scrim#dismiss
7
+ keyup@window->scrim#escape
8
+ scrim:request:hide@window->scrim#hide
9
+ scrim:request:show@window->scrim#show
10
+ ACTIONS
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ <%# controller layout, for use with turbo responses %>
2
+ <%= kpop_frame_tag do %>
3
+ <%= yield %>
4
+ <% end %>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin_all_from Katalyst::Kpop::Engine.root.join("app/assets/javascripts/controllers"),
4
+ under: "controllers",
5
+ # preload in tests so that we don't start clicking before controllers load
6
+ preload: Rails.env.test?
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+
5
+ module Katalyst
6
+ module Kpop
7
+ class Engine < ::Rails::Engine
8
+ config.autoload_once_paths = %W(#{root}/app/helpers)
9
+
10
+ initializer "kpop.helpers", before: :load_config_initializers do
11
+ ActiveSupport.on_load(:action_controller_base) do
12
+ helper Katalyst::Kpop::Engine.helpers
13
+ end
14
+
15
+ ActiveSupport.on_load(:action_view_base) do
16
+ helper Katalyst::Kpop::Engine.helpers
17
+ end
18
+ end
19
+
20
+ initializer "kpop.assets" do
21
+ config.after_initialize do |app|
22
+ if app.config.respond_to?(:assets)
23
+ app.config.assets.precompile += %w(kpop.js)
24
+ end
25
+ end
26
+ end
27
+
28
+ initializer "kpop.importmap", before: "importmap" do |app|
29
+ if app.config.respond_to?(:importmap)
30
+ app.config.importmap.paths << root.join("config/importmap.rb")
31
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Kpop
5
+ VERSION = "2.0.0"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "katalyst/kpop/engine"
4
+ require "katalyst/kpop/version"
5
+
6
+ module Katalyst
7
+ module Kpop
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :yarn do
4
+ desc "Install npm packages with yarn"
5
+ task install: :environment do
6
+ sh "yarn install"
7
+ end
8
+
9
+ desc "Lint JS/SCSS files using yarn (prettier)"
10
+ task lint: :install do
11
+ sh "yarn lint"
12
+ end
13
+
14
+ desc "Autoformat JS/SCSS files using yarn (prettier)"
15
+ task format: :install do
16
+ sh "yarn format"
17
+ end
18
+
19
+ desc "Compile js/css"
20
+ task build: :install do
21
+ sh "yarn build && yarn build:css"
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: katalyst-kpop
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Katalyst Interactive
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-04-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - developers@katalyst.com.au
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - app/assets/builds/katalyst/kpop.css
22
+ - app/assets/builds/katalyst/kpop.min.js
23
+ - app/assets/config/kpop.js
24
+ - app/assets/javascripts/controllers/kpop_controller.js
25
+ - app/assets/javascripts/controllers/scrim_controller.js
26
+ - app/assets/javascripts/katalyst/kpop.js
27
+ - app/assets/stylesheets/katalyst/kpop.scss
28
+ - app/assets/stylesheets/katalyst/kpop/_index.scss
29
+ - app/assets/stylesheets/katalyst/kpop/_kpop.scss
30
+ - app/assets/stylesheets/katalyst/kpop/_scrim.scss
31
+ - app/helpers/kpop/modal.rb
32
+ - app/helpers/kpop_helper.rb
33
+ - app/helpers/scrim_helper.rb
34
+ - app/views/layouts/kpop.html.erb
35
+ - config/importmap.rb
36
+ - config/routes.rb
37
+ - lib/katalyst/kpop.rb
38
+ - lib/katalyst/kpop/engine.rb
39
+ - lib/katalyst/kpop/version.rb
40
+ - lib/tasks/yarn.rake
41
+ homepage: https://github.com/katalyst/kpop
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ rubygems_mfa_required: 'true'
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.4.10
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: Modal library that uses Turbo and Stimulus.
65
+ test_files: []