@dom-native/ui
Version:
Minimalist, stylable, and extensible DOM UI base components based dom-native
296 lines (234 loc) • 7.92 kB
text/typescript
import { BaseHTMLElement, OnEvent, all, customElement, elem, frag, getAttr, html, on, onDoc, onEvent, onWin, position, setAttr, style, trigger } from 'dom-native';
import { BaseFieldElement } from './d-base-field.js';
import { BaseInputElement } from './d-base-input.js';
import { svgSymbolEl } from './d-ico-symbol.js';
const SHADOW_CONTENT = html`
<slot name="icon-lead"></slot>
<slot name="label"></slot>
<div class="box" part="box"></div>
`;
/**
* UNDER REFACTORIZATION DO NOT USE
*
* d-select is a select component.
*
* Usage: `<d-select name="fieldA" value="0" popup-css="my-select-popup"><option value="0">Item 0</option></d-select>`
* See: http://localhost:8080/_spec/controls
*
* Attributes:
* - See BaseFieldElement.
*
* Properties:
* - See BaseFieldElement.
* - `options: Option[]` The list of options object for this field. Can be initialized with HTML content or with the DATA API.
*
* CSS:
* - See BaseFieldElement.
*
* Content (NOT reflective, just for initialization)
* - List of `<option value="1">Value 1</option>` (value must be unique, one can be non present, which === null)
* - or shorthand for one option `<d-select value="1">Value One</d-select>` will create one `<option` with this value/content
* - or shorhand for place holder `<d-select>Select User</d-select>` same as `<d-select placeholder="Select User"></d-select>`
*
* Events:
* - `CHANGE` see BaseFieldElement.
* - `DATA` with `evt.detail: {sendData: (options: Option[]) => void}` that provide a data callback when the component needs the data.
*
*/
export type SelectOption = { content: string, value: string | null };
export type SelectDataSender = (options: SelectOption[]) => void;
export class DSelectElement extends BaseInputElement {
static get observedAttributes() { return BaseFieldElement.observedAttributes.concat(); }
// #region --- Transitive Properties
get popupCss(): string | null { return getAttr(this, 'popup-css') }
// #endregion --- Transitive Properties
#popupEl: SelectPopupElement | null = null;
#value: any;
//// Properties
// options: SelectOption[] = [];
//// Property (Value)
get value() {
return this.#value;
}
set value(v: string | null) {
if (v == null && this.placeholder) {
this.ctrlEl.part.add("placeholder");
this.ctrlEl.textContent = this.placeholder;
} else if (v != null) {
this.ctrlEl.part.remove("placeholder");
this.ctrlEl.textContent = v;
}
this.#value = v;
}
// #region --- Component Events
triggerData(sendData: SelectDataSender) {
trigger(this, 'D-DATA', { detail: { sendData } });
}
// #endregion --- Component Events
// #region --- UI Events
onClick(evt: PointerEvent) {
if (!this.#popupEl && !this.disabled && !this.readonly) {
let options = all(this, "option").map((el) => { return { content: el.innerHTML, value: getAttr(el, "value") } })
let popupEl = elem('d-select-popup', { class: this.popupCss ?? '' });
popupEl._setupData = [this, options];
// Append it to the body.
document.body.appendChild(popupEl);
// TODO - handle the focus logic
// this.classList.add('d-focus');
this.#popupEl = popupEl;
// listen the popup if select occurs
on(popupEl, 'SELECT, CANCEL', (evt) => {
if (evt.type === 'SELECT') {
this.value = evt.detail.value;
this.triggerChange();
this.refresh();
} else {
// do nothing, assume the popup auto hide
}
// this.classList.remove('d-focus');
this.#popupEl = null;
});
// trigger a data event if a listener wants to provide data
this.triggerData((options: SelectOption[]) => {
this.#popupEl?.refreshOptions(options);
});
}
// We discard/cancel the popup
else if (this.#popupEl) {
this.#popupEl.discard(true);
this.#popupEl = null;
}
}
// #endregion --- UI Events
constructor() {
super();
// add the popup component
this.shadowRoot!.appendChild(elem('slot', { name: 'popup' }));
}
// #region --- Lifecycle
// Component initialization (will be called once by BaseHTMLElement on first connectedCallback)
init() {
super.init();
this.append(setAttr(svgSymbolEl('d-ico-chevron-down'), { slot: 'icon-trail' }));
this.classList.add('has-icon-trail');
}
// #endregion --- Lifecycle
refresh() {
}
// #region --- BaseInput Implementations
createCtrlEl(): HTMLElement {
return elem('div');
}
getInitialValue() {
return getAttr(this, 'value');
}
// #endregion --- BaseInput Implementations
}
declare global {
interface HTMLElementTagNameMap {
'd-select': DSelectElement;
}
}
// #region --- SelectPopupElement
const SELECT_POPUP_POSITION = Object.freeze({ loc: 'bottom', align: 'left' } as const);
interface WithData { _data: any }
/**
* Component to be used only by the SelectElement (for now).
* Events:
* - `SELECT` when an item is selected
* - `CANCEL` when the user click outside
*/
class SelectPopupElement extends BaseHTMLElement {
_setupData!: [DSelectElement, SelectOption[]?];
previousSelectElRect?: DOMRect;
get selectEl(): DSelectElement { return this._setupData[0] }
get selectOptions(): SelectOption[] | undefined { return this._setupData[1] }
// #region --- UI Events
onClickOption(evt: Event & OnEvent) {
let target = (<any>evt.selectTarget) as WithData;
let option = target._data;
let selectEl = this._setupData?.[0];
trigger(this, "SELECT", { detail: option });
this.discard(false);
}
removeOnScroll(evt: Event) {
// TODO: Add some padding to not close on small scrolls, or prevent scroll all together (making it modal)
this.discard(true);
}
onRepositionEvents(evt: Event) {
this.reposition();
}
// Auto remove when click outide of parent
// (click on parent, d-select, will be responsibility of d-select)
onDocClick(evt: PointerEvent) {
if (!this.selectEl.contains(evt.target as Element) && !this.contains(evt.target as Element)) {
this.discard(true);
}
}
// #endregion --- UI Events
// #region --- Lifecycle
init() {
this.refreshOptions();
}
// #endregion --- Lifecycle
refreshOptions(options?: SelectOption[]) {
options = options ?? this._setupData?.[1];
if (options != null) {
let content = frag(options, (o) => elem("div", {
class: "option",
value: o.value || "",
$: {
textContent: o.content,
_data: o
}
}))
this.replaceChildren(content);
}
this.reposition();
}
discard(cancel: boolean) {
this.remove();
if (cancel) {
trigger(this, 'CANCEL');
}
}
reposition() {
const parentRect = this.selectEl.getBoundingClientRect();
if (parentRect != null && !isSameRect(parentRect, this.previousSelectElRect)) {
const hGap = 4;
let width = this.selectEl.clientWidth - hGap * 2;
style(this, { width: `${width + 1}px` });
position(this, this.selectEl, {
pos: "BL",
vGap: 8,
hGap
});
// NOTE: This is a hack otherwise when the window has small height
// the popup sometime goes below the d-textara text content.
// Really seems to be a UI bug, and only way found so far is to change
// a ui property on this popup (hence the +1 above, and the value here)
setTimeout(() => {
style(this, { width: `${width}px` });
}, 20);
}
this.previousSelectElRect = parentRect;
}
}
// Augment the global TagName space to match runtime
declare global {
interface HTMLElementTagNameMap {
'd-select-popup': SelectPopupElement;
}
}
function isSameRect(a: DOMRect, b?: DOMRect): boolean {
if (b == null) return false;
return (a.top == b.top && a.left == b.left && a.right == b.right && a.bottom == b.bottom);
}
// #endregion --- SelectPopupElement