diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af6b56a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.dccache \ No newline at end of file diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..021cf75 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,3 @@ +{ + "esversion": 9 +} \ No newline at end of file diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md index ee60d3e..36624b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,150 @@ -# modern-context.js +# ModernContext.js + +[日本語](README_ja.md) + A modern, beautiful, and lightweight context menu JavaScript library inspired by Fluent Design. + +![screenshot](screenshot_light.png) + +## Dark Mode Support + +ModernContext.js supports dark mode. If your browser is set to dark mode, the context menu will automatically switch to the black-based design. + +![screenshot](screenshot_dark.png) + +## Supported Browsers + +The following browsers are supported. ModernContext.js may work in other modern browsers, but I tested only the following browsers. + +- Google Chrome +- Firefox +- Microsoft Edge + +Note: Firefox does not currently supports CSS ``backdrop-filter`` property, so the blur effect behind of the context is not active if you are using Firefox. + +## Usage + +```javascript +const context = new Context("#target"); + +context.add_item("Alert", () => { + alert("Clicked!") +}); +context.add_separator(); +context.add_item("No Callback"); +``` + +The following code will have the same behavior. + +```javascript +const context = new Context("#target"); + +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +context.add_contents(contents); +``` + +And you can also write the following. + +```javascript +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +const context = new Context("#target", contents); +``` + +### Arguments + +#### Context() + +|Name|Value Type|Default|Description| +|--:|--:|--:|--:| +|target_selector|String|N/A|CSS selector of the target element.| +|contents|Array|[ ]|The contents of the context menu. This argument is optional. For more detail, see ``add_contents()``.| + +#### add_item() + +Add a item to the context menu. + +|Name|Value Type|Default|Description| +|--:|--:|--:|--:| +|label|String|N/A|The label of the item.| +|callback|Function|() => {}|When the user select the item, this function will be called.| + +#### add_separator() + +Add a separator to the context menu. This function has no arguments. + +#### add_contents() + +Add item(s) or separator(s) to the context menu. + +|Name|Value Type|Default|Description| +|--:|--:|--:|--:| +|contents|Array of Object|N/A|Array of contents you want to add to the context menu.| + +##### Example of add_contents() + +```javascript +const context = new Context(); + +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +context.add_contents(contents); +``` + +#### open() + +Open the context menu. Since the process of opening the context menu by right-clicking is handled by the library, you should not need to use this function. + +|Name|Value Type|Default|Description| +|--:|--:|--:|--:| +|event|MouseEvent|N/A|Mouse event.| + +#### close() + +Close the context menu. Since the process of closing the opened context menu in response to a user operation is handled by the library, you should not need to use this function. This function has no arguments. diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..dfa32c6 --- /dev/null +++ b/README_ja.md @@ -0,0 +1,150 @@ +# ModernContext.js + +[English](README.md) + +Fluent Designに影響を受けた、モダンで美しく軽量なJavaScriptのコンテキストメニューのライブラリーです。 + +![screenshot](screenshot_light.png) + +## ダークモードをサポート + +ModernContext.jsはダークモードをサポートしています。ブラウザーがダークモードに設定されている場合、コンテキストメニューは自動で黒を基調としたデザインに切り替わります。 + +![screenshot](screenshot_dark.png) + +## サポートしているブラウザー + +次のブラウザーがサポートされています。ModernContext.jsは他のモダンブラウザーでも動作するかもしれませんが、リストにあるブラウザーでのみテストしています。 + +- Google Chrome +- Firefox +- Microsoft Edge + +注意:Firefoxは現在、CSSの``backdrop-filter``プロパティーをサポートしていないため、コンテキストメニューの背景のブラーエフェクトはFirefoxでは動作しません。 + +## 使い方 + +```javascript +const context = new Context("#target"); + +context.add_item("Alert", () => { + alert("Clicked!") +}); +context.add_separator(); +context.add_item("No Callback"); +``` + +次のコードでも同じように動作します。 + +```javascript +const context = new Context("#target"); + +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +context.add_contents(contents); +``` + +また、次のように書くこともできます。 + +```javascript +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +const context = new Context("#target", contents); +``` + +### 引数 + +#### Context() + +|名前|引数の型|デフォルト|説明| +|--:|--:|--:|--:| +|target_selector|String|N/A|ターゲットとする要素のCSSセレクター| +|contents|Array Of Object|[ ]|コンテキストメニューの内容。この引数は省略可能です。詳細は``add_contents()``を参照| + +#### add_item() + +コンテキストメニューにアイテムを追加します。 + +|名前|引数の型|デフォルト|説明| +|--:|--:|--:|--:| +|label|String|N/A|アイテムのラベル| +|callback|Function|() => {}|ユーザーがアイテムを選択したとき、この関数が呼び出されます| + +#### add_separator() + +コンテキストメニューにセパレーターを追加します。引数はありません。 + +#### add_contents() + +コンテキストメニューにアイテムやセパレーターを追加します。 + +|名前|引数の型|デフォルト|説明| +|--:|--:|--:|--:| +|contents|Array of Object|N/A|コンテキストメニューに追加する内容の配列| + +##### add_contents()の例 + +```javascript +const context = new Context(); + +const contents = [ + { + type: "item", + label: "Alert", + callback: () => { + alert("Clicked!"); + } + }, + { + type: "separator" + }, + { + type: "item", + label: "No Callback" + }, +]; + +context.add_contents(contents); +``` + +#### open() + +コンテキストメニューを開きます。右クリックによってコンテキストメニューを開く処理はライブラリー側で行うため、基本的にこの関数を使用することはないはずです。 + +|名前|引数の型|デフォルト|説明| +|--:|--:|--:|--:| +|event|MouseEvent|N/A|マウスイベント| + +#### close() + +コンテキストメニューを閉じます。開かれたコンテキストメニューをユーザーの操作に応じて閉じる処理はライブラリー側で行うため、基本的にこの関数を使用することはないはずです。引数はありません。 diff --git a/context.js b/context.js new file mode 100644 index 0000000..2dc4646 --- /dev/null +++ b/context.js @@ -0,0 +1,266 @@ +class Context { + constructor(target_selector, contents = []) { + const style = document.createElement("style"); + style.textContent = ` +:root { + --text_color: #333333; + --background_color: rgba(255, 255, 255, 0.7); + --corner_radius: 0.25em; + --font-family: sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --text_color: white; + --background_color: rgba(51, 51, 51, 0.7); + } +} + +.context_menu_js_outer { + background: var(--background_color); + position: absolute; + border-radius: var(--corner_radius); + box-shadow: 0.1em 0.1em 0.75em rgba(0, 0, 0, 0.3); + padding: 0.5em 0; + display: none; + overflow: hidden; + transition: 0.3s cubic-bezier(0.5, 0, 0, 1); + cursor: default; + user-select: none; + backdrop-filter: blur(0.25em); + font-family: var(--font-family); +} + +.context_menu_js_outer hr { + width: calc(100% - 2em); + height: 0.1em; + background: var(--text_color); + border: none; + margin: 0.25em 1em; + opacity: 0.5; +} + +.context_menu_js_outer .context_item { + width: 100%; + padding: 0.5em 1em; + color: var(--text_color); + box-sizing: border-box; + position: relative; +} + +.context_menu_js_outer .context_item::before { + content: ""; + display: block; + width: 100%; + height: 100%; + transition: 0.1s; + position: absolute; + top: 0; + left: 0; + background: var(--text_color); + opacity: 0; +} + +.context_menu_js_outer .context_item.hover::before { + opacity: 0.15; +} + +.context_menu_js_outer .context_item .context_item_inner { + transition: 0.1s; +} + +.context_menu_js_outer .context_item:active .context_item_inner { + transform: scale(0.9); +} + `; + document.body.appendChild(style); + + this.context = document.createElement("div"); + this.context.className = "context_menu_js_outer"; + document.body.appendChild(this.context); + + this.add_contents(contents); + + document.querySelectorAll(target_selector).forEach((target) => { + target.addEventListener("contextmenu", () => { + this.open(event); + event.preventDefault(); + }); + }); + + document.addEventListener("click", () => { + if (event.target !== this.context) this.close(); + }, false); + + document.addEventListener("keydown", this._watch_keydown.bind(this), false); + this.is_visible = false; + } + + add_item(label, callback = () => { }) { + const item = document.createElement("div"); + item.className = "context_item"; + item.addEventListener("click", () => { + callback(); + }); + item.addEventListener("mouseover", () => { + this._hover(item); + }); + item.addEventListener("mouseleave", () => { + this._reset_all_hover_status(); + }); + + const inner = document.createElement("div"); + inner.className = "context_item_inner"; + inner.textContent = label; + + item.appendChild(inner); + this.context.appendChild(item); + } + + add_separator() { + this.context.appendChild(document.createElement("hr")); + } + + add_contents(contents) { + for (let i = 0; i < contents.length; i++) { + const content = contents[i]; + + const types = ["item", "separator"]; + + if (types.includes(content.type) === false) continue; + + switch (content.type) { + case "item": + const item = { + ...{ + label: "", + callback: () => { } + }, + ...content + }; + this.add_item(item.label, item.callback); + break; + + case "separator": + this.add_separator(); + break; + } + } + } + + open(event) { + const context_show_transition_ms = "300"; + this.context.style.transition = "none"; + + if (event.screenY < window.innerHeight / 2) { + this.context.style.bottom = "auto"; + this.context.style.top = `${event.pageY}px`; + } else { + this.context.style.top = "auto"; + this.context.style.bottom = `${window.innerHeight - event.pageY}px`; + } + + if (event.screenX < window.innerWidth / 2) { + this.context.style.right = "auto"; + this.context.style.left = `${event.pageX}px`; + } else { + this.context.style.left = "auto"; + this.context.style.right = `${window.innerWidth - event.pageX}px`; + } + + this.context.style.display = "block"; + + const context_height = window.getComputedStyle(this.context).getPropertyValue("height"); + this.context.style.height = "0"; + this.context.style.transition = `${context_show_transition_ms}ms`; + + setTimeout(() => { + this.context.style.height = `${context_height}`; + + setTimeout(() => { + this.context.style.height = "auto"; + }, context_show_transition_ms); + }, 1); + + this.is_visible = true; + } + + close() { + this.context.style.display = "none"; + + this._reset_all_hover_status(); + this.is_visible = false; + } + + _watch_keydown(key) { + if (this.is_visible === false) return; + + const current_selected_item = this.context.querySelector(".context_item.hover") || this.context.querySelector(".context_item"); + const number_of_items = this.context.querySelectorAll(".context_item").length; + const hovered_item_index = this._hovered_item_index(); + const no_selected = hovered_item_index === null; + + switch (key.key) { + case "Escape": + const div = document.createElement("div"); + div.style.display = "none"; + document.body.appendChild(div); + div.click(); + div.remove(); + break; + + case "ArrowDown": + if (no_selected) { + this._hover(0); + break; + } + + const next_item_index = hovered_item_index + 1 < number_of_items ? hovered_item_index + 1 : 0; + this._hover(next_item_index); + break; + + case "ArrowUp": + if (no_selected) { + this._hover(number_of_items - 1); + break; + } + + const previous_item_index = hovered_item_index - 1 >= 0 ? hovered_item_index - 1 : number_of_items - 1; + this._hover(previous_item_index); + break; + + case "Enter": + current_selected_item.click(); + break; + } + + event.preventDefault(); + } + + _reset_all_hover_status() { + this.context.querySelectorAll(".context_item.hover").forEach((element) => { + element.classList.remove("hover"); + }); + } + + _hover(item) { + this._reset_all_hover_status(); + + if (typeof (item) == "number") { + this.context.querySelectorAll(".context_item").item(item).classList.add("hover"); + } else if (typeof (item) === "object") { + item.classList.add("hover"); + } + } + + _hovered_item_index() { + const hovered_item = this.context.querySelector(".context_item.hover"); + const context_items = this.context.querySelectorAll(".context_item"); + if (!hovered_item) { + return null; + } + for (let i = 0; i < context_items.length; i++) { + if (hovered_item === context_items[i]) return i; + } + } +} diff --git a/context.min.js b/context.min.js new file mode 100644 index 0000000..b2a8081 --- /dev/null +++ b/context.min.js @@ -0,0 +1 @@ +class Context{constructor(target_selector,contents=[]){const style=document.createElement("style");style.textContent='\n:root {\n --text_color: #333333;\n --background_color: rgba(255, 255, 255, 0.7);\n --corner_radius: 0.25em;\n --font-family: sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --text_color: white;\n --background_color: rgba(51, 51, 51, 0.7);\n }\n}\n\n.context_menu_js_outer {\n background: var(--background_color);\n position: absolute;\n border-radius: var(--corner_radius);\n box-shadow: 0.1em 0.1em 0.75em rgba(0, 0, 0, 0.3);\n padding: 0.5em 0;\n display: none;\n overflow: hidden;\n transition: 0.3s cubic-bezier(0.5, 0, 0, 1);\n cursor: default;\n user-select: none;\n backdrop-filter: blur(0.25em);\n font-family: var(--font-family);\n}\n\n.context_menu_js_outer hr {\n width: calc(100% - 2em);\n height: 0.1em;\n background: var(--text_color);\n border: none;\n margin: 0.25em 1em;\n opacity: 0.5;\n}\n\n.context_menu_js_outer .context_item {\n width: 100%;\n padding: 0.5em 1em;\n color: var(--text_color);\n box-sizing: border-box;\n position: relative;\n}\n\n.context_menu_js_outer .context_item::before {\n content: "";\n display: block;\n width: 100%;\n height: 100%;\n transition: 0.1s;\n position: absolute;\n top: 0;\n left: 0;\n background: var(--text_color);\n opacity: 0;\n}\n\n.context_menu_js_outer .context_item.hover::before {\n opacity: 0.15;\n}\n\n.context_menu_js_outer .context_item .context_item_inner {\n transition: 0.1s;\n}\n\n.context_menu_js_outer .context_item:active .context_item_inner {\n transform: scale(0.9);\n}\n ',document.body.appendChild(style),this.context=document.createElement("div"),this.context.className="context_menu_js_outer",document.body.appendChild(this.context),this.add_contents(contents),document.querySelectorAll(target_selector).forEach(target=>{target.addEventListener("contextmenu",()=>{this.open(event),event.preventDefault()})}),document.addEventListener("click",()=>{event.target!==this.context&&this.close()},!1),document.addEventListener("keydown",this._watch_keydown.bind(this),!1),this.is_visible=!1}add_item(label,callback=(()=>{})){const item=document.createElement("div");item.className="context_item",item.addEventListener("click",()=>{callback()}),item.addEventListener("mouseover",()=>{this._hover(item)}),item.addEventListener("mouseleave",()=>{this._reset_all_hover_status()});const inner=document.createElement("div");inner.className="context_item_inner",inner.textContent=label,item.appendChild(inner),this.context.appendChild(item)}add_separator(){this.context.appendChild(document.createElement("hr"))}add_contents(contents){for(let i=0;i{},...content};this.add_item(item.label,item.callback);break;case"separator":this.add_separator()}}}open(event){const context_show_transition_ms="300";this.context.style.transition="none",event.screenY{this.context.style.height=`${context_height}`,setTimeout(()=>{this.context.style.height="auto"},"300")},1),this.is_visible=!0}close(){this.context.style.display="none",this._reset_all_hover_status(),this.is_visible=!1}_watch_keydown(key){if(!1===this.is_visible)return;const current_selected_item=this.context.querySelector(".context_item.hover")||this.context.querySelector(".context_item"),number_of_items=this.context.querySelectorAll(".context_item").length,hovered_item_index=this._hovered_item_index(),no_selected=null===hovered_item_index;switch(key.key){case"Escape":const div=document.createElement("div");div.style.display="none",document.body.appendChild(div),div.click(),div.remove();break;case"ArrowDown":if(no_selected){this._hover(0);break}const next_item_index=hovered_item_index+1=0?hovered_item_index-1:number_of_items-1;this._hover(previous_item_index);break;case"Enter":current_selected_item.click()}event.preventDefault()}_reset_all_hover_status(){this.context.querySelectorAll(".context_item.hover").forEach(element=>{element.classList.remove("hover")})}_hover(item){this._reset_all_hover_status(),"number"==typeof item?this.context.querySelectorAll(".context_item").item(item).classList.add("hover"):"object"==typeof item&&item.classList.add("hover")}_hovered_item_index(){const hovered_item=this.context.querySelector(".context_item.hover"),context_items=this.context.querySelectorAll(".context_item");if(!hovered_item)return null;for(let i=0;i + + + + + + Document + + +
+ + + + + diff --git a/screenshot_dark.png b/screenshot_dark.png new file mode 100644 index 0000000..299fb4e Binary files /dev/null and b/screenshot_dark.png differ diff --git a/screenshot_light.png b/screenshot_light.png new file mode 100644 index 0000000..0b4c85c Binary files /dev/null and b/screenshot_light.png differ