@silexlabs/grapesjs-advanced-selector
Version:
A GrapesJS plugin for managing advanced CSS selectors with a visual interface
290 lines (274 loc) • 10.7 kB
text/typescript
import { html, css, TemplateResult, svg } from 'lit'
import StylableElement from '../StylableElement'
import { property } from 'lit/decorators.js'
import { ComplexSelector, fromString, specificity, toString } from '../model/ComplexSelector'
import { createRef, ref } from 'lit/directives/ref.js'
import { customizeSelect, FOCUS_VISIBLE } from '../styles'
// Inline SVG icons (stroke-based, inherit color via currentColor)
const ICON_SIZE = 14
const iconEdit = svg`<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
const iconCopy = svg`<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
const iconPaste = svg`<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>`
const iconClear = svg`<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21"/><path d="m5.082 11.09 8.828 8.828"/></svg>`
const iconHelp = svg`<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`
const iconWarning = svg`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`
export class CurrentSelectorDisplay extends StylableElement {
/**
* The selector to display
*/
({ type: Object, attribute: true, reflect: false })
public get value(): ComplexSelector | undefined {
return this._value
}
public set value(value: ComplexSelector | string | undefined) {
try {
this._value = typeof value === 'string' ? JSON.parse(value) : value
this.specificity = specificity(this._value!)
} catch (error) {
console.error('Error parsing value for selector', { value, error })
}
}
private _value: ComplexSelector | undefined
({ type: String, attribute: true, reflect: false })
public placeholder = 'Select an option'
({ type: Array, attribute: true, reflect: false })
public selectors: ComplexSelector[] = []
({ type: String, attribute: true, reflect: false })
public helpLink = ''
({ type: String, attribute: true, reflect: false })
public error = ''
({ type: String, attribute: true, reflect: false })
public warning = ''
public specificity = 0
private selectRef = createRef<HTMLSelectElement>()
static override styles = css`
:host {
${ FOCUS_VISIBLE }
& {
font-size: 0.65rem;
padding: 0.25rem 0;
}
.selection {
display: flex;
border: 1px solid var(--gjs-primary-color, #333);
border-radius: 4px;
font-size: .85rem;
${ customizeSelect('select.value') }
select.value {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 1px solid var(--gjs-light-border, #333);
font-size: inherit;
font-family: monospace;
text-align: center;
margin: .15rem;
padding: .1rem 0;
text-wrap: wrap;
width: 100%;
cursor: pointer;
background: var(--gjs-primary-color);
color: inherit;
border-radius: 3px;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
& > li {
display: inline;
margin: 0 0.15rem;
}
}
aside {
ul {
display: flex;
align-items: center;
height: 100%;
}
button, a {
background-color: transparent;
color: var(--gjs-font-color-active, #f8f8f8);
cursor: pointer;
padding: 2px;
margin: 0;
border: none;
opacity: 0.35;
display: inline-flex;
align-items: center;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
}
.specificity {
font-size: 9px;
cursor: default;
opacity: 0.4;
}
}
}
.asm-display__help {
text-decoration: none;
color: var(--gjs-secondary-color, #333);
&:hover {
color: var(--gjs-font-color-active, #fff);
}
}
.asm-display__error {
color: var(--gjs-warning-color, #f90);
margin: 0;
padding: 2px 0;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 10px;
a {
text-decoration: none;
color: var(--gjs-secondary-color, #333);
display: inline-flex;
margin-left: 0.5rem;
&:hover {
color: var(--gjs-font-color-active, #fff);
}
}
}
.asm-display__warning {
color: var(--gjs-warning-color, #f90);
margin: 0;
padding: 2px 0;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 10px;
a {
text-decoration: none;
color: var(--gjs-secondary-color, #333);
display: inline-flex;
margin-left: 0.5rem;
&:hover {
color: var(--gjs-font-color-active, #fff);
}
}
}
}
`
override render(): TemplateResult {
if (!this.value) {
return html``
}
const thisSelectorString = toString(this.value)
const selectors = [
{ selector: this.value, selectorString: thisSelectorString },
...this.selectors
.sort((a, b) => specificity(b) - specificity(a))
.map(s => ({ selector: s, selectorString: toString(s) }))
.filter(({ selector, selectorString }) =>
thisSelectorString !== selectorString
|| selector.atRule !== this.value?.atRule
)
]
const selectorsStrings = selectors
.map(({ selectorString, selector }) => {
return html`
${ selector.atRule ? selector.atRule.replace(/@media \(max-width: (.+)\)/, '@$1') : '' }
${ selectorString }
`
})
//const uniqueStrings = new Set(selectorsStrings)
// Workaround: the selected option do not update when the value changes after user selects an option
requestAnimationFrame(() => this.selectRef.value ? this.selectRef.value!.selectedIndex = 0 : '')
return html`
<main id="pre" class="selection">
<select
${ ref(this.selectRef) }
class="value"
@change=${(event: Event) => {
event.stopPropagation()
const target = event.target as HTMLSelectElement
const sel = selectors[parseInt(target.value)].selector
this.changeSelector(sel)
}}
>
${selectorsStrings
.map((selectorString, idx) => {
return html`
<option
value=${idx}
?selected=${idx === 0}
>
${ selectorString }
</option>`
})}
</select>
<aside>
<ul>
<li
.title=${ this.t('Specificity') }
class="specificity"
>
${this.specificity}
</li>
<li>
<button
.title=${ this.t('Edit selector') }
@click=${() => {
const newSelector = prompt(this.t('Edit selector'), toString(this.value!))
this.changeSelector(newSelector ? fromString(newSelector, this.value!.atRule ?? '') : this.value!)
}}
>${iconEdit}</button>
</li>
<li>
<button
.title=${ this.t('Copy style') }
@click=${() => this.dispatchEvent(new CustomEvent('copy'))}
>${iconCopy}</button>
</li>
<li>
<button
.title=${ this.t('Paste style') }
@click=${() => this.dispatchEvent(new CustomEvent('paste'))}
>${iconPaste}</button>
</li>
<li>
<button
.title=${ this.t('Clear style for this selector') }
@click=${() => {
this.clearStyle()
}}
>${iconClear}</button>
</li>
${ this.helpLink ? html`
<li>
<a
class="asm-display__help"
.title=${ this.t('Help') }
.href=${ this.helpLink }
target="_blank"
>${iconHelp}</a>
</li>
` : ''}
</ul>
</aside>
</main>
<footer>
${ this.error ? html`
<p class="asm-display__error">${iconWarning} ${ this.error } <a href="https://docs.silex.me/en/user/selectors#troubleshooting" target="_blank" title="${this.t('Troubleshooting guide')}">${iconHelp}</a></p>
` : ''}
${ this.warning ? html`
<p class="asm-display__warning">${iconWarning} ${ this.warning } <a href="https://docs.silex.me/en/user/selectors#troubleshooting" target="_blank" title="${this.t('Troubleshooting guide')}">${iconHelp}</a></p>
` : ''}
</footer>
`
}
private changeSelector(sel: ComplexSelector) {
this.value = sel
this.dispatchEvent(new CustomEvent('change', { detail: sel }))
}
private clearStyle() {
this.dispatchEvent(new CustomEvent('delete', { detail: this.value }))
}
}
if (!customElements.get('current-selector-display')) {
customElements.define('current-selector-display', CurrentSelectorDisplay)
}