UNPKG

@silexlabs/grapesjs-advanced-selector

Version:

A GrapesJS plugin for managing advanced CSS selectors with a visual interface

538 lines (510 loc) 17.2 kB
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 */ @property({ 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 */ @property({ type: Array, attribute: true, reflect: false }) public suggestions: SimpleSelector[] = [] /** * Whether the selector is editable */ @property({ 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 @property({ 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" @keydown=${(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { this.value = { ...this.value!, active: !this.value!.active } this.dispatchEvent(new CustomEvent('change', { detail: this.value })) } }} @dblclick=${() => this.edit()} @click=${() => { 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" @keydown=${(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.stopPropagation() } }} @click=${(event: MouseEvent) => { this.dispatchEvent(new CustomEvent('delete', { detail: this.value })) // Avoid check/uncheck the "active" checkbox event.stopPropagation() }} > &times; </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) } @keydown=${(event: KeyboardEvent) => { if ( !this.editing && event.target === this.mainRef.value && event.key === 'Enter' ) { this.edit() event.stopPropagation() } }} @focusout=${(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} @keydown=${(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() } }} @keyup=${() => { const input = this.selectorInputRef.value if (input) { input.setCustomValidity(valid ? '' : 'Invalid selector') input.reportValidity() } this.requestUpdate() }} @click=${(event: MouseEvent) => event.stopPropagation()} @input=${(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" @click=${ (event: MouseEvent) => { // Avoid check/uncheck the "active" checkbox event.stopPropagation() }} @change=${(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 })) }} @keydown=${(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` "&nbsp; <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 })) }} /> &nbsp;" ` : ''} ` } } if (!customElements.get('simple-selector')) { customElements.define('simple-selector', SimpleSelectorComponent) }