Skip to content

Instantly share code, notes, and snippets.

@nicholaswmin
Last active November 17, 2024 08:37
Show Gist options
  • Save nicholaswmin/e565610ce5ab55c923f6c33fd050715a to your computer and use it in GitHub Desktop.
Save nicholaswmin/e565610ce5ab55c923f6c33fd050715a to your computer and use it in GitHub Desktop.
data-bound CSS custom properties
/*
UI controls bound to CSS Custom Properties for on-the-fly tweaks.
Also draws an auto-updating grid.
> - updates to this gist must republish a new gisthostfor.me
> - only works for numeric props for now
It's a native WebComponent, so it can be dropped in style guides
without affecting or being affected by the environmental CSS.
Usage Example:
- Observe 3 CSS Custom Properties
- Draw grid in `document.body`
> note: grid size is based on:
> `--base-size * --line-height`.
>
> defaults to:
> `<computed-font-size>` * `<computed-line-height>`,
> if the above are not defined.
## Usage
Define in HTML:
```html
<css-controls></css-controls>
```
Initialize in JS:
```HTML
<script type="module">
import 'https://cdn.gisthostfor.me/nicholaswmin-3rvdlgY3uJ-css-controls.js'
const controls = document.querySelector('css-controls')
controls.setup({
grid: document.body,
props: {
'--base-size': { title: 'size of body text' },
'--line-height': { min: 1, max: 5, step: 0.1, title: 'line height' },
'--scale': { max: 3, step: 0.1, title: 'scale factor' }
}
})
// Use this to change a CSS property "externally",
// since it keeps the UI controls & CSS props in-sync.
// controls.set('--base-size', 14)
</script>
```
### Author:
- @nicholaswmin, published under the MIT License
*/
class CSSControls extends HTMLElement {
constructor() {
super()
this.grid = null
this.props = []
}
setup({ grid, props }) {
this.grid = grid
this.props = Object.entries(props).map(CSSProp.fromEntry.bind(this))
this.render(this.props)
this.drawGrid()
}
render(props) {
const shadow = this.attachShadow({ mode: 'open' })
shadow.appendChild(this.createHideable(
this.createForm(props.map(this.onChange(this.drawGrid)))
))
shadow.appendChild(this.createCSS(`
:host {
display: block; position: fixed; top: 0; left: 0; width: 100%;
font-size: 14px; line-height: 16px; padding: 0;
background: rgb(255 255 255 / 80%);
summary { margin: 1em; cursor: pointer; }
fieldset {
display: inline-block; border: none;
input:invalid { background: rgb(240 0 0 / 15%); }
input { min-width: 60px; margin: 6px; }
label, input { display: block; }
}
}
`))
}
get(name) {
return +getComputedStyle(document.body)
.getPropertyValue(name).replace(/[^.\d]/g, '')
}
set(name, value) {
const prop = this.props.find(prop => prop.name === name)
return prop ? prop.set(value, true) : console.error('no such prop', name)
}
createForm(props) {
return props.reduce(
this.createFieldsets.bind(this),
document.createElement('form')
)
}
createHideable(element) {
const details = document.createElement('details')
const summary = document.createElement('summary')
summary.innerText = 'toggle'
details.open = true
details.appendChild(element)
details.appendChild(summary)
return details
}
createFieldsets(form, { name, element }) {
const field = document.createElement('fieldset')
const label = document.createElement('label')
field.appendChild(label)
field.appendChild(element)
label.for = name
label.innerText = name
form.appendChild(field)
return form
}
createCSS(css) {
const style = document.createElement('style')
style.textContent = css
return style
}
onChange(fn) {
return prop => {
prop.element.addEventListener('change', fn.bind(this))
return prop
}
}
drawGrid() {
const rgba = `rgba(0, 119, 179, 0.2)`
const grad = `linear-gradient(${rgba} 1px, transparent 1px) left top`
const base = this.get('--base-size') || this.get('font-size')
const line = this.get('--line-height') || this.get('line-height')
const size = base * line
this.grid.style.paddingTop = `${size}px`;
this.grid.style.background = `${grad } / ${size}px ${size}px`
}
}
class CSSProp {
constructor(name, { min = 1, max = 99, ...attributes }) {
this.name = name
this.value = this.get()
this.min = min
this.max = max
this.element = this.createInput({ min, max, ...attributes })
this.element.addEventListener('input', this.set.bind(this))
}
createInput({ ...attributes }) {
const element = document.createElement('input')
element.setAttribute('id', this.name)
element.setAttribute('type', 'number')
element.setAttribute('value', this.value)
Object.entries(attributes)
.forEach(([name, value]) => element.setAttribute(name, value))
return element
}
get() {
return +getComputedStyle(document.body)
.getPropertyValue(this.name).replace(/[^.\d]/g, '')
}
set(value, syncElementValue = false) {
value = typeof value === 'object' ? value.currentTarget.value : value
this.element.setAttribute('invalid', this.element.checkValidity())
this.value = value > this.min && value < this.max ? value : this.value
document.documentElement.style.setProperty(this.name, this.value)
if (syncElementValue) this.element.value = this.value
return this
}
static fromEntry([ name, value ]) {
return new CSSProp(name, value)
}
}
customElements.define('css-controls', CSSControls)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment