-
-
Save griponminds/506f75ae0aa7d12e72a85571727a635f to your computer and use it in GitHub Desktop.
Bookmarklet for CSS Container Queries
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
javascript:(() => { | |
/*! cq-resizer.js 2.0.2 | © 2023 grip on minds | MIT License (https://opensource.org/license/mit/) */ | |
class CqResizer extends HTMLElement { | |
/* preferences */ | |
#title = 'cq-resizer'; | |
/* CSS */ | |
#styles = ` | |
@layer reset, layouts, components, theme; | |
@layer theme { | |
:host { | |
--color-fg : hsl(0 0% 6%); | |
--color-bg : hsl(170 5% 84%); | |
--color-border : hsl(0 0% 40%); | |
--color-accent : hsl(170 100% 23%); | |
--color-scheme : light; | |
--font-family : sans-serif; | |
--font-size : calc(1rem * 12 / var(--rem, 16)); | |
--line-height : 1.4; | |
--inline-size : min(40em, calc(100% - 40px)); | |
} | |
} | |
@layer components { | |
h1 { | |
padding-block: 10px; | |
padding-inline: 20px; | |
border-inline-end: solid 1px var(--color-border); | |
font-size: inherit; | |
font-weight: normal; | |
cursor: move; | |
} | |
.message { | |
padding: 20px; | |
} | |
ol { | |
display: grid; | |
gap: 20px; | |
padding: 20px; | |
counter-set: list-counter; | |
} | |
li { | |
display: grid; | |
grid-template-columns: auto 1fr auto auto; | |
gap: 20px; | |
align-items: center; | |
} | |
a { | |
color: var(--color-fg); | |
overflow-wrap: anywhere; | |
text-decoration: none; | |
} | |
.size { | |
display: grid; | |
grid-template-columns: repeat(2, auto); | |
column-gap: 5px; | |
align-content: start; | |
} | |
input[type="number"] { | |
inline-size: 5em; | |
padding-inline-start: 0.5em; | |
text-align: end; | |
} | |
input[type="number"][readonly] { | |
color: hsl(0 0% 40%); | |
} | |
button { | |
display: grid; | |
grid-template-columns: 1fr 16px; | |
column-gap: 4px; | |
align-items: center; | |
inline-size: 100%; | |
block-size: 100%; | |
padding-block: 10px; | |
padding-inline: 20px; | |
border: none; | |
border-radius: 0; | |
background: none; | |
appearance: none; | |
cursor: pointer; | |
} | |
@media (any-hover: hover) { | |
a:hover, | |
button:hover { | |
opacity: 0.7; | |
} | |
} | |
} | |
@property --x { | |
syntax: '<length>'; | |
inherits: false; | |
initial-value: 0; | |
} | |
@property --y { | |
syntax: '<length>'; | |
inherits: false; | |
initial-value: 0; | |
} | |
@layer layouts { | |
:host { | |
position: fixed; | |
inset-block-start: 0; | |
inset-inline-end: 0; | |
z-index: calc(infinity); | |
display: var(--display, block); | |
inline-size: var(--inline-size); | |
color-scheme: var(--color-scheme); | |
font-family: var(--font-family); | |
font-size: var(--font-size); | |
line-height: var(--line-height); | |
translate: var(--x, 0) var(--y, 0); | |
} | |
:host[data-is-move] { | |
will-change: translate; | |
} | |
.widget { | |
display: grid; | |
overflow: auto; | |
overscroll-behavior: contain; | |
inline-size: calc(100% - 40px); | |
block-size: auto; | |
max-block-size: calc(100svh - 40px - var(--y, 0px)); | |
margin: 20px; | |
border: solid 1px var(--color-border); | |
background-color: var(--color-bg); | |
color: var(--color-fg); | |
} | |
.header { | |
position: sticky; | |
inset-block-start: 0; | |
z-index: 1; | |
display: grid; | |
grid-template-columns: 1fr auto; | |
justify-content: space-between; | |
align-items: center; | |
border-block-end: solid 1px var(--color-border); | |
background: var(--color-bg); | |
} | |
} | |
:host { | |
all: revert-layer !important; | |
} | |
@layer reset { | |
:host { | |
writing-mode: horizontal-tb; | |
font-style: initial; | |
font-weight: initial; | |
font-variant: initial; | |
line-height: initial; | |
letter-spacing: initial; | |
text-align: initial; | |
text-indent: initial; | |
text-transform: initial; | |
word-spacing: initial; | |
text-shadow: initial; | |
cursor: initial; | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
} | |
*, | |
::after, | |
::before { | |
box-sizing: border-box; | |
} | |
ol { | |
list-style-type: none; | |
} | |
input, button { | |
font: inherit; | |
} | |
button { | |
border: none; | |
background: none; | |
letter-spacing: inherit; | |
cursor: pointer; | |
} | |
:where( | |
button, | |
button:active | |
) { | |
color: inherit; | |
} | |
} | |
`; | |
/* アンカーリンクの CSS */ | |
#anchorStyles = ` | |
[data-cq-resizer-anchor] { | |
position: absolute; | |
inset: 0; | |
z-index: calc(infinity); | |
display: none; | |
border: solid 10px green; | |
opacity: 0; | |
pointer-events: none; | |
} | |
[data-cq-resizer-anchor="target"] { | |
display: block; | |
animation-name: highlight; | |
animation-duration: 1s; | |
animation-timing-function: ease; | |
animation-iteration-count: 2; | |
} | |
@keyframes highlight { | |
0% { opacity: 0; } | |
50% { opacity: 0.6; } | |
100% { opacity: 0; } | |
} | |
@media (prefers-reduced-motion: reduce) { | |
[data-cq-resizer-anchor="target"] { | |
animation-timing-function: step-end; | |
animation-iteration-count: 1; | |
} | |
} | |
`; | |
/* elements */ | |
#containers = null; | |
#html = null; | |
#shadow = null; | |
#anchorStyleElem = document.createElement('style'); | |
/* others */ | |
#idPrefix = 'cq-resizer'; | |
#pos = new Map([ | |
['x', 0], | |
['y', 0], | |
['fromX', 0], | |
['fromY', 0], | |
['moveX', 0], | |
['moveY', 0], | |
]); | |
#mainController = new AbortController(); | |
#moveController = undefined; | |
#resizeObserver = undefined; | |
constructor() { | |
super(); | |
} | |
connectedCallback() { | |
const styles = this.createStyles(); | |
this.#containers = this.getContainers(); | |
this.#html = this.createHTML(); | |
if (!this.#html) return; | |
this.calcFontSizeRem(); | |
this.#anchorStyleElem.textContent = this.#anchorStyles; | |
document.body.append(this.#anchorStyleElem); | |
this.#shadow = this.attachShadow({ mode: 'open' }); | |
this.#shadow.append(styles); | |
this.#shadow.append(this.#html); | |
this.createAnchors(); | |
const moveTarget = this.#html.querySelector('[data-move-target]'); | |
const closeBtn = this.#html.querySelector('[data-close]'); | |
const links = this.#html.querySelectorAll('a'); | |
const inputNums = this.#html.querySelectorAll('input[type="number"]'); | |
const checkboxes = this.#html.querySelectorAll('input[type="checkbox"]'); | |
const { signal } = this.#mainController; | |
if (moveTarget) { | |
moveTarget.addEventListener('mousedown', this.handleMoveStart, { signal }); | |
moveTarget.addEventListener('touchstart', this.handleMoveStart, { signal }); | |
} | |
closeBtn?.addEventListener('click', () => { | |
this.setAttribute('data-remove', 'true'); | |
}, { signal }); | |
links.forEach((link) => { | |
link.addEventListener('click', () => { | |
const anchor = document.querySelector(link.getAttribute('href')); | |
anchor && anchor.setAttribute('data-cq-resizer-anchor', 'target'); | |
}, { signal }); | |
}); | |
inputNums.forEach((input) => { | |
this.setInputNumberEvent(input); | |
}); | |
checkboxes.forEach((checkbox) => { | |
checkbox.addEventListener('change', () => { | |
this.changeCheckbox(checkbox, checkbox.checked); | |
}, { signal }); | |
}); | |
} | |
disconnectedCallback() { | |
this.#mainController?.abort(); | |
this.#moveController?.abort(); | |
this.#resizeObserver?.disconnect(); | |
} | |
/* ウィジェットの削除 */ | |
static get observedAttributes() { return ['data-remove']; } | |
attributeChangedCallback(name, _, newValue) { | |
if (name === 'data-remove') { | |
if (newValue === 'true') { | |
this.resetContainers(); | |
this.removeAnchors(); | |
const parent = this.parentElement; | |
parent && parent.removeChild(this); | |
} | |
} | |
} | |
/* CSS 生成 */ | |
createStyles = () => { | |
const styleElem = document.createElement('style'); | |
styleElem.textContent = this.#styles; | |
return styleElem; | |
}; | |
/* `input` からサイズの変更 */ | |
setInputNumberEvent = (input) => { | |
const id = input?.getAttribute('data-input-id') ?? ''; | |
const container = document.querySelector(`[data-container-id=${id}]`); | |
if (!container) return; | |
const { signal } = this.#mainController; | |
input.addEventListener('change', () => { | |
if (input.value >= 0) { | |
container.style.setProperty('width', `${input.value}px`); | |
} | |
}, { signal }); | |
this.#resizeObserver = new ResizeObserver((entries) => { | |
for (let entry of entries) { | |
input.value = Math.round(entry.borderBoxSize[0].inlineSize); | |
} | |
}); | |
this.#resizeObserver.observe(container); | |
}; | |
/* チェックボックスの変更 */ | |
changeCheckbox = (checkbox, checked) => { | |
const id = checkbox?.getAttribute('data-input-id') ?? ''; | |
const inputNum = this.#html.querySelector(`input[type="number"][data-input-id=${id}]`); | |
const container = document.querySelector(`[data-container-id=${id}]`); | |
if (!inputNum || !container) return; | |
if (checked) { | |
inputNum.removeAttribute('readonly'); | |
container.setAttribute('style', container.getAttribute('data-cq-resizer-style')); | |
} else { | |
inputNum.setAttribute('readonly', 'readonly'); | |
container.setAttribute('data-cq-resizer-style', container.getAttribute('style')); | |
container.removeAttribute('style'); | |
} | |
}; | |
/* 親子関係にあり XY 座標が同じ子孫要素のコンテナを除外 */ | |
removeSamePosContainers = (containers) => { | |
return containers.map((el) => { | |
const elRect = el.getBoundingClientRect(); | |
const isSamePosition = containers.some((parent) => { | |
if (parent === el) return false; | |
const parentRect = parent.getBoundingClientRect(); | |
if ( | |
parent.contains(el) && | |
(Math.round(parentRect.right) === Math.round(elRect.right)) && | |
(Math.round(parentRect.bottom) === Math.round(elRect.bottom)) | |
) { | |
return true; | |
} | |
return false; | |
}); | |
if (isSamePosition) return false; | |
return el; | |
}).filter(Boolean); | |
}; | |
/* `container-type: size` が一致する要素を取得 */ | |
getContainers = () => { | |
const all = document.querySelectorAll('*'); | |
const containers = Array.from(all).map((el) => { | |
const style = window.getComputedStyle(el); | |
return style.containerType.match(/size/) ? el : false; | |
}).filter(Boolean); | |
return this.removeSamePosContainers(containers); | |
}; | |
/* コンテナのスタイルとカスタムデータ属性をリセット */ | |
resetContainers = () => { | |
this.#containers.forEach((container) => { | |
const style = window.getComputedStyle(container); | |
if (style.containerType.includes('inline-size')) { | |
container.style.removeProperty('overflow-x'); | |
} else if (style.containerType.includes('size')) { | |
container.style.removeProperty('overflow'); | |
} | |
container.style.removeProperty('width'); | |
container.style.removeProperty('height'); | |
container.style.removeProperty('resize'); | |
container.removeAttribute('data-container-id'); | |
}); | |
}; | |
/* アンカーとスタイルの削除 */ | |
removeAnchors = () => { | |
const anchors = document.querySelectorAll('[data-cq-resizer-anchor]'); | |
anchors.forEach((anchor) => { | |
const parent = anchor.parentElement; | |
parent && parent.removeChild(anchor); | |
}); | |
const styleParent = this.#anchorStyleElem.parentElement; | |
styleParent && styleParent.removeChild(this.#anchorStyleElem); | |
}; | |
/* アンカー生成 */ | |
createAnchors = () => { | |
const { signal } = this.#mainController; | |
this.#containers.forEach((container, index) => { | |
const anchor = document.createElement('span'); | |
anchor.setAttribute('id', `${this.#idPrefix}-${index + 1}`); | |
anchor.setAttribute('aria-hidden', 'true'); | |
anchor.setAttribute('data-cq-resizer-anchor', 'true'); | |
anchor.addEventListener('animationend', () => { | |
anchor.setAttribute('data-cq-resizer-anchor', 'true'); | |
}, { signal }); | |
container.append(anchor); | |
}); | |
}; | |
/* HTML の特殊文字をエンティティ化 */ | |
sanitizeHTML = (str) => { | |
if (typeof str !== 'string') return str; | |
return str | |
.replace(/[<>&'`"]/ug, (match) => { | |
return { | |
'<' : '<', | |
'>' : '>', | |
'&' : '&', | |
'\'': ''', | |
'`' : '`', | |
'"' : '"' | |
}[match]; | |
}); | |
}; | |
/* リンクテキスト生成 */ | |
createLinkText = (container) => { | |
const array = Array.from(container.classList); | |
if (array.length < 1) { | |
return `<${container?.tagName.toLowerCase()}>`; | |
} | |
return `.${array.join('.')}`; | |
}; | |
/* リストアイテムの HTML を生成 */ | |
createListItemHTML = () => { | |
if (!this.#containers) return; | |
let str = ''; | |
this.#containers.forEach((container, index) => { | |
const style = window.getComputedStyle(container); | |
/* コンテナにリサイズハンドルを追加 */ | |
if (style.containerType.includes('inline-size')) { | |
container.style.setProperty('resize', 'horizontal'); | |
container.style.setProperty('overflow-x', 'auto'); | |
} else if (style.containerType.includes('size')) { | |
container.style.setProperty('resize', 'both'); | |
container.style.setProperty('overflow', 'auto'); | |
} | |
const size = String(parseInt(getComputedStyle(container).getPropertyValue('inline-size'), 10)); | |
const id = `${this.#idPrefix}-${index + 1}`; | |
container.setAttribute('data-container-id', id); | |
str += ` | |
<li> | |
<span>${index + 1}</span> | |
<a href="#${id}"> | |
${this.sanitizeHTML(this.createLinkText(container))} | |
</a> | |
<div class="size"> | |
<input type="number" name="size" value=${size} data-input-id="${id}" />px | |
</div> | |
<input type="checkbox" name="show" data-input-id="${id}" checked /> | |
</li> | |
`; | |
}); | |
return str; | |
}; | |
/* リストの HTML を生成 */ | |
createListHTML = () => { | |
if (this.#containers.length < 1) { | |
return ` | |
<p class="message"> | |
<code>container-type</code> が指定されている要素は見つかりませんでした | |
</p> | |
`; | |
} | |
return ` | |
<ol> | |
${this.createListItemHTML()} | |
</ol> | |
`; | |
}; | |
/* HTML 生成 */ | |
createHTML = () => { | |
const str = ` | |
<div class="widget" role="region" aria-labelledby="title"> | |
<div class="header" data-move-target> | |
<h1 id="title">${this.sanitizeHTML(this.#title)}</h1> | |
<button type="button" data-close> | |
Close | |
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"> | |
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1"/> | |
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1"/> | |
</svg> | |
</button> | |
</div> | |
${this.createListHTML()} | |
</div> | |
`; | |
const html = new DOMParser().parseFromString(str, 'text/html'); | |
return html.body.firstElementChild; | |
}; | |
/* ルート要素のフォントサイズを算出 */ | |
calcFontSizeRem = () => { | |
const rem = getComputedStyle(document.documentElement).getPropertyValue('font-size'); | |
this.style.setProperty('--rem', String(parseInt(rem, 10))); | |
}; | |
/* イベントを取得 */ | |
getEvent = (e) => { | |
return ('touches' in e) ? e.touches[0] : e; | |
}; | |
/* ウィジェットをドラッグして移動 */ | |
handleMoveStart = (e) => { | |
e.preventDefault(); | |
this.#moveController = new AbortController(); | |
const { signal } = this.#moveController; | |
const event = this.getEvent(e); | |
this.setAttribute('data-is-move', 'true'); | |
this.#pos.set('fromX', event.clientX); | |
this.#pos.set('fromY', event.clientY); | |
document.addEventListener('mousemove', this.handleMove, { passive: false, signal }); | |
document.addEventListener('touchmove', this.handleMove, { passive: false, signal }); | |
document.addEventListener('mouseup', this.handleMoveEnd, { signal }); | |
document.addEventListener('touchend', this.handleMoveEnd, { signal }); | |
}; | |
handleMove = (e) => { | |
e.preventDefault(); | |
const event = this.getEvent(e); | |
this.#pos.set('moveX', event.clientX - this.#pos.get('fromX')); | |
this.#pos.set('moveY', event.clientY - this.#pos.get('fromY')); | |
this.#pos.set('x', this.#pos.get('x') + this.#pos.get('moveX')); | |
this.#pos.set('y', this.#pos.get('y') + this.#pos.get('moveY')); | |
this.style.setProperty('--x', `${this.#pos.get('x')}px`); | |
this.style.setProperty('--y', `${this.#pos.get('y')}px`); | |
this.#pos.set('fromX', event.clientX); | |
this.#pos.set('fromY', event.clientY); | |
}; | |
handleMoveEnd = () => { | |
this.removeAttribute('data-is-move'); | |
this.#moveController.abort(); | |
}; | |
} | |
/* カスタム要素の初期化 */ | |
{ | |
if (!customElements.get('cq-resizer')) { | |
customElements.define('cq-resizer', CqResizer); | |
} | |
const widget = document.querySelector('cq-resizer'); | |
if (!widget) { | |
const newWidget = document.createElement('cq-resizer'); | |
document.body.prepend(newWidget); | |
newWidget.setAttribute('data-init', 'true'); | |
} | |
if (widget) { | |
widget.setAttribute('data-remove', 'true'); | |
} | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment