@silexlabs/grapesjs-advanced-selector
Version:
A GrapesJS plugin for managing advanced CSS selectors with a visual interface
242 lines (225 loc) • 8.25 kB
text/typescript
import { property } from 'lit/decorators.js'
import StylableElement from '../StylableElement'
import { CompoundSelector, toString } from '../model/CompoundSelector'
import { isSameSelector, SimpleSelector, SimpleSelectorType, specificity } from '../model/SimpleSelector'
import { css, html, TemplateResult } from 'lit'
import SimpleSelectorComponent from './simple-selector'
import { PSEUDO_CLASSES } from '../model/PseudoClass'
import { FOCUS_VISIBLE } from '../styles'
import './inline-select'
import './simple-selector'
/**
* A component to display and edit a compound selector
* A compound selector is a list of simple selectors and a pseudo class
* @emits change
* @emits rename (when a simple selector in the selector is renamed)
* @emits delete
* @emits add
*/
export default class CompoundSelectorComponent extends StylableElement {
// /////////////////
// Attributes
/**
* The selector to display
*/
({ type: Object, attribute: true, reflect: false })
public get value(): CompoundSelector | undefined {
return this._value
}
public set value(value: CompoundSelector | string | undefined) {
try {
this._value = typeof value === 'string' ? JSON.parse(value) : value
} catch (error) {
console.error('Error parsing value for selector', { value, error })
}
this.requestUpdate()
}
private _value: CompoundSelector | undefined
/**
* A list of all the classes, IDs, tags, custom tags, attributes, custom attributes
* that are available in the document, applicable to the current selection
*/
({ type: Array, attribute: true, reflect: false })
public suggestions: SimpleSelector[] = []
({ type: Boolean, attribute: 'disable-pseudo-class' })
public disablePseudoClass: boolean = false
// /////////////////
// Properties
// /////////////////
// Element overrides
static override styles = css`
:host {
${ FOCUS_VISIBLE }
button:hover, a:hover {
transform: translateX(1px);
font-weight: bold;
}
.asm-compound__selectors {
display: flex;
gap: 0.35rem;
align-items: center;
flex-wrap: wrap;
padding: 0.3rem 0.5rem;
background-color: var(--gjs-main-dark-color);
border-radius: 4px;
}
.asm-compound__add {
color: var(--gjs-secondary-color, #b9a5a6);
opacity: 0.6;
transition: opacity 0.15s ease;
}
.asm-compound__add:hover {
opacity: 1;
}
.asm__add-inline {
font-size: 0.75rem;
background: transparent;
opacity: 0.6;
padding: 2px 4px;
}
}
`
// override dispatchEvent(event: Event): boolean {
// console.info('[COMPOUND] Dispatching event', event)
// return super.dispatchEvent(event)
// }
override toString(): string {
return toString(this.value!)
}
override render(): TemplateResult {
return html`
<section>
<div
class="asm-compound__selectors"
>
${ this.value?.selectors
.sort((a, b) => specificity(b, true) - specificity(a, true))
.map((selector, idx) => html`
<simple-selector
.t=${ this.t }
.value=${ selector }
.suggestions=${ this.suggestions }
?readonly=${ ![SimpleSelectorType.CLASS, SimpleSelectorType.UNKNOWN].includes(selector.type) }
@change=${ (event: CustomEvent<SimpleSelector>) => this.changeSelector(event, idx) }
@delete=${ (event: CustomEvent) => this.deleteSelector(event, idx) }
></simple-selector>
`) }
<button
id="gjs-clm-add-tag"
class="gjs-clm-tags-btn gjs-clm-tags-btn__add asm-compound__add"
.title=${ this.t('Add a new selector') }
@click=${ this.addSelector }
>
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path></svg>
</button>
</div>
${ this.disablePseudoClass ? '' : html`
${ this.value?.pseudoClass ? html`
<div>
<inline-select
.t=${ this.t }
.value=${ this.value?.pseudoClass }
.options=${ PSEUDO_CLASSES }
@change=${ this.changePseudoClass }
placeholder=""
></inline-select>
</div>
` : html`
<button
class="gjs-btn-prim asm__add-inline"
@click=${ this.addPseudoClass }
>\u2192 ${ this.t('Pseudo Class') }</button>
` }
`}
</section>
`
}
// /////////////////
// Methods
private changeSelector(event: CustomEvent<SimpleSelector>, idx: number) {
const oldValue: SimpleSelector = this.value!.selectors[idx]
const onlyId = event.detail.type === SimpleSelectorType.ID && event.detail.active
// Update the selector at the given index
let updatedSelectors = this.value!.selectors
.map((selector, i) => i === idx ? event.detail : selector)
// When ID is activated, deactivate all other selectors
if (onlyId) {
updatedSelectors = updatedSelectors.map(selector =>
selector.type !== SimpleSelectorType.ID ? { ...selector, active: false } : selector
)
}
// When a NEW class is created (type changed from UNKNOWN to CLASS), deactivate all other classes
// But allow multiple existing classes to be manually activated for selectors like .a.b
const isNewlyCreatedClass = oldValue?.type === SimpleSelectorType.UNKNOWN
&& event.detail.type === SimpleSelectorType.CLASS
&& event.detail.active
if (isNewlyCreatedClass) {
updatedSelectors = updatedSelectors.map(selector =>
selector.type === SimpleSelectorType.CLASS && !isSameSelector(selector, event.detail)
? { ...selector, active: false }
: selector
)
}
// When all selectors are deactivated but pseudo class exists, activate ID selector
const hasActiveSelectors = updatedSelectors.some(selector => selector.active)
if (!hasActiveSelectors && this.value!.pseudoClass) {
updatedSelectors = updatedSelectors.map(selector =>
selector.type === SimpleSelectorType.ID ? { ...selector, active: true } : selector
)
}
this.value = {
...this.value!,
selectors: updatedSelectors,
}
if(!isSameSelector(oldValue, event.detail, false)) {
this.dispatchEvent(new CustomEvent('rename', { detail: {
oldValue,
value: event.detail,
} }))
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}
event.stopPropagation()
this.requestUpdate()
}
private addSelector(event: MouseEvent) {
this.value = this.value ?? { selectors: [] }
this.value.selectors.push({ type: SimpleSelectorType.UNKNOWN, active: true })
event.stopPropagation()
this.requestUpdate()
// Make the last selector editable
requestAnimationFrame(() => this.focusLastSelector())
}
private focusLastSelector() {
if (!this.value) {
return
}
const selector = this.shadowRoot!.querySelectorAll('simple-selector')[this.value!.selectors.length - 1] as SimpleSelectorComponent
selector.editing = true
requestAnimationFrame(() => {
selector.focus()
})
}
private deleteSelector(event: CustomEvent, idx: number) {
const removed: SimpleSelector | undefined = this.value?.selectors.splice(idx, 1)[0]
this.dispatchEvent(new CustomEvent('delete', { detail: removed }))
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
event.stopPropagation()
this.requestUpdate()
}
private addPseudoClass(event: MouseEvent) {
this.value = { ...this.value!, pseudoClass: PSEUDO_CLASSES[0] }
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
event.stopPropagation()
}
private changePseudoClass(event: CustomEvent) {
this.value = {
...this.value!,
pseudoClass: event.detail,
}
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
event.stopPropagation()
}
}
if (!customElements.get('compound-selector')) {
customElements.define('compound-selector', CompoundSelectorComponent)
}