katalyst-kpop 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []