@silexlabs/grapesjs-advanced-selector
Version:
A GrapesJS plugin for managing advanced CSS selectors with a visual interface
538 lines (510 loc) • 17.2 kB
text/typescript
import { css, html, TemplateResult } from 'lit'
import { property } from 'lit/decorators.js'
import { styleMap } from 'lit/directives/style-map.js'
import { AttributeSelector, SimpleSelector, SimpleSelectorSuggestion, toString, getDisplayType, getDisplayName, suggest, validate, getCreationSuggestions, SimpleSelectorType, getEditableName, STYLE_FOR_TYPE, AttributeOperatorType } from '../model/SimpleSelector'
import StylableElement from '../StylableElement'
import { createRef, ref } from 'lit/directives/ref.js'
import { customizeInput, customizeSelect, FOCUS_VISIBLE } from '../styles'
import './resize-input'
export default class SimpleSelectorComponent extends StylableElement {
// /////////////////
// Attributes
/**
* The selector to display
*/
({ type: Object, attribute: true, reflect: false })
public get value(): SimpleSelector | undefined {
return this._value
}
public set value(sel: SimpleSelector | string | undefined) {
try {
this._value = typeof sel === 'string' ? JSON.parse(sel) : sel
} catch (error) {
console.error('Error parsing value for selector', { sel, error })
}
this.requestUpdate()
}
private _value: SimpleSelector | 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[] = []
/**
* Whether the selector is editable
*/
({ type: Boolean, attribute: true, reflect: false, state: true })
public get editing(): boolean {
return this._editing
}
public set editing(isEditing: boolean) {
this._editing = isEditing
}
private _editing = false
({ type: Boolean, attribute: true, reflect: false })
readonly = false
// /////////////////
// Properties
private selectorInputRef = createRef<HTMLInputElement>()
private attributeOptionsAttrValueRef = createRef<HTMLInputElement>()
private mainRef = createRef<HTMLElement>()
private datalistSelectionInProgress = false
// /////////////////
// Element overrides
static override styles = css`
:host {
${ FOCUS_VISIBLE }
section {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.25rem;
padding: 0.10rem;
border-radius: 0.2rem;
cursor: pointer;
background-color: var(--gjs-main-light-color, #f9f9f9);
}
section:has(:invalid) {
background: rgba(255, 0, 0, 0.1) !important;
}
header {
width: 12px;
text-wrap: nowrap;
transition: all .2s ease-out;
overflow: hidden;
text-align: center;
line-height: 1;
}
section:focus-within header,
section:hover header {
width: 0;
}
section.asm-simple-selector__id:focus-within header,
section.asm-simple-selector__id:hover header {
width: 12px;
}
footer {
width: 0;
transition: all .2s ease-out;
overflow: hidden;
}
section:focus-within footer,
section:hover footer {
width: 12px;
}
section.asm-simple-selector__id:focus-within footer,
section.asm-simple-selector__id:hover footer {
width: 0;
}
select {
text-align: center;
}
input, select, button {
font-family: inherit;
font-size: inherit;
color: var(--gjs-secondary-color, #333);
}
.asm-simple-selector__delete-button {
padding: 0;
line-height: 1;
margin: 1px;
background: transparent;
color: var(--gjs-color-warn, #f00);
font-size: 1.1rem;
}
/*
.asm-simple-selector__delete-button:hover {
color: var(--gjs-color-warn, #f00);
transform: scale(2) translateY(-1px);
}
*/
.asm-simple-selector__active {
display: none;
}
${ customizeInput('.asm-simple-selector__like-text') }
.asm-simple-selector__like-text {
padding: .25rem;
}
${ customizeSelect('.asm-simple-selector__options-select') }
.asm-simple-selector__name {
display: inline-flex;
text-wrap: wrap;
justify-content: center;
align-items: center;
min-height: 20px;
line-height: 1;
user-select: none;
}
.asm-simple-selector__selector {
cursor: text;
min-width: 200px;
}
/* Try to style datalist options - browser support varies */
datalist {
position: absolute;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--gjs-border-color, #ddd);
border-radius: 4px;
background: var(--gjs-main-bg-color, white);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Some browsers support styling datalist options */
datalist option {
padding: 8px 12px;
border-bottom: 1px solid var(--gjs-border-light-color, #eee);
background: var(--gjs-main-bg-color, white);
color: var(--gjs-main-color, #333);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 250px;
}
datalist option:hover {
background: var(--gjs-main-light-color, #f9f9f9);
}
datalist option:last-child {
border-bottom: none;
}
}
/* FIXME: this should be inside :host but it breaks opactity when visible */
section:not(:has(.asm-simple-selector__active:checked)):not(:has(.asm-simple-selector__selector)) {
opacity: 0.5;
}
`
override focus() {
this.selectorInputRef.value?.focus()
}
override render(): TemplateResult {
if(!this.value) return html`<div>Initializing</div>`
return html`
<section
class="${this.value.type === SimpleSelectorType.ID ? 'asm-simple-selector__id' : ''}"
tabindex="0"
=${(event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
this.value = { ...this.value!, active: !this.value!.active }
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}
}}
=${() => this.edit()}
=${() => {
this.value = { ...this.value!, active: !this.value!.active }
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}}
>
<header>
${!this.editing ? getDisplayType(this.value) : ''}
</header>
${ this.renderMain() }
<footer>
${this.value.type !== SimpleSelectorType.ID ? html`
<button
.title=${ this.t('Delete') }
class="gjs-btn-prim asm-simple-selector__delete-button"
=${(event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}}
=${(event: MouseEvent) => {
this.dispatchEvent(new CustomEvent('delete', { detail: this.value }))
// Avoid check/uncheck the "active" checkbox
event.stopPropagation()
}}
>
×
</button>
` : ''}
<input
type="checkbox"
autocomplete="off"
class="asm-simple-selector__active"
.checked=${this.value.active}
/>
</footer>
</section>
`
}
// override dispatchEvent(event: Event): boolean {
// console.info('[SIMPLE] Dispatching event', event)
// return super.dispatchEvent(event)
// }
// /////////////////
// Methods
private edit() {
if (this.hasAttribute('readonly')) {
console.warn('Cannot edit a readonly selector')
return
}
this.editing = true
requestAnimationFrame(() => this.focus())
}
private select(selector: SimpleSelectorSuggestion) {
// Remove the createText property
const newSelector = { ...selector }
delete newSelector.createText
delete newSelector.createValue
// Reactive: this.selector = newSelector
if (!selector || selector.keepEditing) {
requestAnimationFrame(() => this.focus())
} else {
this.editing = false
this.attributeOptionsAttrValueRef.value?.blur()
}
this.dispatchEvent(new CustomEvent('change', { detail: newSelector }))
}
private cancelEdit() {
this.attributeOptionsAttrValueRef.value?.blur()
if (!this.editing) return
this.editing = false
if(this.value?.type === SimpleSelectorType.UNKNOWN) {
this.dispatchEvent(new CustomEvent('delete', { detail: this.value }))
} else {
this.dispatchEvent(new CustomEvent('cancel', { detail: this.value }))
}
}
// /////////////////
// Lifecycle methods
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties)
if (this.value) {
const selectorString = this.selectorInputRef.value?.value || ''
this.selectorInputRef.value?.setCustomValidity(validate(selectorString) === false ? 'Invalid selector' : '')
}
}
// /////////////////
// Render methods
private renderMain(): TemplateResult {
const selectorString = this.selectorInputRef.value?.value || ''
const valid = validate(selectorString)
const suggestions = getCreationSuggestions(valid, selectorString).concat(suggest(selectorString, this.suggestions))
return html`
${ this.renderLayout(html`
${ this.editing ? this.renderSelectorInput({
suggestions,
valid: valid !== false, // Can be false or a string
}) : ''
}
${ this.editing && this.value?.type === SimpleSelectorType.ATTRIBUTE ? this.renderOptionsEditor() : html``}
${ !this.editing ? this.renderSelector(html`
${this.value?.type === SimpleSelectorType.ATTRIBUTE ? this.renderOptionsEditor() : html``}
`) : '' }
${this.editing ? this.renderSuggestionList(suggestions, selectorString) : '' }
`, { valid: valid !== false, suggestions }) }
`
}
private renderLayout(content: TemplateResult, { valid, suggestions }: {suggestions: SimpleSelectorSuggestion[], valid: boolean}): TemplateResult {
return html`
<main
style=${styleMap(STYLE_FOR_TYPE[this.value!.type])}
tabindex="0"
${ ref(this.mainRef) }
=${(event: KeyboardEvent) => {
if (
!this.editing
&& event.target === this.mainRef.value
&& event.key === 'Enter'
) {
this.edit()
event.stopPropagation()
}
}}
=${(event: FocusEvent) => {
if(!this.editing) return
if(this.datalistSelectionInProgress) {
// Don't interfere with datalist selection
return
}
const newFocus = event.relatedTarget as HTMLElement
if (this.renderRoot.querySelector('main')!.contains(newFocus)) {
// Focus is still inside the component
return
}
if (valid) this.select(suggestions[0])
else requestAnimationFrame(() => this.focus())
event.stopPropagation()
}}
>
${content}
</main>
`
}
private renderSelector(content: TemplateResult): TemplateResult {
return html`
<span
class="asm-simple-selector__name"
>
${this.value ? html`
${ getDisplayName(this.value!) }
${content}
` : 'No selector'}
</span>
`
}
private renderSelectorInput({ suggestions, valid }: { suggestions: SimpleSelectorSuggestion[], valid: boolean }) {
return html`
<input
${ref(this.selectorInputRef)}
list="suggestions"
is="resize-input"
type="text"
autocomplete="off"
.value=${getEditableName(this.value!)}
.disabled=${!this.editing}
class="asm-simple-selector__like-text asm-simple-selector__selector"
aria-invalid=${!valid}
=${(event: KeyboardEvent) => {
if (!this.value) return
if (event.key === 'Escape') {
this.cancelEdit()
event.stopPropagation()
} else if (event.key === 'Enter') {
if (valid) this.select(suggestions[0])
event.stopPropagation()
}
}}
=${() => {
const input = this.selectorInputRef.value
if (input) {
input.setCustomValidity(valid ? '' : 'Invalid selector')
input.reportValidity()
}
this.requestUpdate()
}}
=${(event: MouseEvent) => event.stopPropagation()}
=${(event: InputEvent) => {
const inputValue = (event.target as HTMLInputElement).value
// Detect datalist selection by checking:
// 1. Input type is insertion (indicates auto-completion)
// 2. Value matches a complete suggestion exactly
// 3. Value is not empty/whitespace only
const isInsertion = event.inputType === 'insertReplacementText' || event.inputType === 'insertCompositionText'
const matchingSuggestion = suggestions.find(s =>
(s.createValue ?? toString(s)) === inputValue
)
if (isInsertion && matchingSuggestion && inputValue.trim() !== '') {
this.datalistSelectionInProgress = true
// Small delay to allow browser to finish processing the datalist selection
setTimeout(() => {
this.select(matchingSuggestion)
this.datalistSelectionInProgress = false
}, 50)
}
}}
/>
`
}
private renderSuggestionList(suggestions: SimpleSelectorSuggestion[], selectorString: string): TemplateResult {
return html`
<datalist
id="suggestions"
>
${ suggestions
.sort((a, b) => {
if (toString(a) === selectorString) return -1
if (toString(b) === selectorString) return 1
return 0
})
.map((sel) => html`
<option
value=${sel.createValue ?? toString(sel)}
>${sel.createText ?? toString(sel)}</option>
`)}
</datalist>
`
}
/**
* Option editor to edit the selector options, depending on the type
* Only the attribute selectors have options: `operator` and `value2`
*/
private renderOptionsEditor(): TemplateResult {
if (this.value?.type !== SimpleSelectorType.ATTRIBUTE) throw new Error('Invalid selector type, only attribute selectors have options')
const selector = this.value as AttributeSelector
return html`
<select
class="asm-simple-selector__options-select"
id="operator"
=${ (event: MouseEvent) => {
// Avoid check/uncheck the "active" checkbox
event.stopPropagation()
}}
=${(event: Event) => {
const operator = (event.target as HTMLSelectElement).value as AttributeOperatorType
// Create a new selector object to trigger Lit's reactivity
this.value = {
...selector,
operator,
attributeValue: operator ? this.attributeOptionsAttrValueRef.value?.value || '' : ''
} as AttributeSelector
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}}
=${(event: KeyboardEvent) => {
if (event.key === ' ') {
event.stopPropagation()
}
}}
>
<option value="">...</option>
<option value="=" .selected=${selector.operator === '='}>=</option>
<option value="~=" .selected=${selector.operator === '~='}>~=</option>
<option value="|=" .selected=${selector.operator === '|='}>|=</option>
<option value="^=" .selected=${selector.operator === '^='}>^=</option>
<option value="$=" .selected=${selector.operator === '$='}>$=</option>
<option value="*=" .selected=${selector.operator === '*='}>*=</option>
</select>
${selector.operator ? html`
"
<input
is="resize-input"
type="text"
${ref(this.attributeOptionsAttrValueRef)}
autocomplete="off"
class="asm-simple-selector__like-text"
.value=${selector.attributeValue ?? ''}
@click=${ (event: MouseEvent) => {
// Avoid check/uncheck the "active" checkbox
event.stopPropagation()
}}
@keydown=${(event: KeyboardEvent) => {
if (event.key === 'Enter') {
// Create a new selector object to trigger Lit's reactivity
this.value = {
...selector,
attributeValue: (event.target as HTMLInputElement).value
} as AttributeSelector
this.select(this.value!)
event.stopPropagation()
} else if (event.key === 'Escape') {
;(event.target as HTMLInputElement).value = selector.attributeValue ?? ''
this.cancelEdit()
event.stopPropagation()
}
}}
@focusout=${(event: FocusEvent) => {
// Create a new selector object to trigger Lit's reactivity
this.value = {
...selector,
attributeValue: (event.target as HTMLInputElement).value
} as AttributeSelector
this.select(this.value!)
}}
@input=${(event: InputEvent) => {
// Create a new selector object to trigger Lit's reactivity
this.value = {
...selector,
attributeValue: (event.target as HTMLInputElement).value
} as AttributeSelector
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}}
/>
"
` : ''}
`
}
}
if (!customElements.get('simple-selector')) {
customElements.define('simple-selector', SimpleSelectorComponent)
}