scrapbox-suggest-container-3
from /programming-notes/scrapbox-suggest-container-3
コンテナ本体
code:script.js
import {css} from './container-css.js';
import {create} from './item.js';
customElements.define('suggest-container', class extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML =
`<style>${css}</style>
<ul id="box"></ul>`;
}
connectedCallback() {
this.hide();
}
push(...items) {
this._box.append(...items.map(props => create(props)));
if (this.mode === 'auto') this.show();
}
pushFirst(...items) {
if (this.items.length === 0) {
this.push(...items);
return;
}
const index = this.selectedItemIndex;
const fragment = new DocumentFragment();
fragment.append(...items.map(props => create(props)));
this._box.insertBefore(fragment, this.firstItem);
if (index !== -1) this.itemsindex + items.length.select();
if (this.mode === 'auto') this.show();
}
replace(...items) {
this._box.textContent = '';
this.push(...items);
}
pop(...indices) {
indices.forEach(index => this.itemsindex?.remove?.());
if (this.items.length === 0) this.hide();
}
clear() {
this._box.textContent = '';
this.hide();
}
get items() {
return this._box.childNodes;
}
get selectableItems() {
return ...this.items.filter(item => !item.disabled);
}
get firstItem() {
return this._box.firstElementChild;
}
get lastItem() {
return this._box.lastElementChild;
}
get firstSelectableItem() {
return ...this.items.find(item => !item.disabled);
}
get lastSelectableItem() {
return ...this.items.reverse().find(item => !item.disabled);
}
get selectedItem() {
return ...this.items.find(item => item.isSelected);
}
get selectedItemIndex() {
return ...this.items.findIndex(item => item.isSelected);
}
selectNext({wrap}) {
if (!this.firstSelectableItem) return;
const selectedItem = this.selectedItem;
this.selectedItem?.deselect?.();
if (!selectedItem || (wrap && this.lastItem === selectedItem)) {
this.firstSelectableItem?.select?.();
this._box.scroll(0, 0);
} else{
selectedItem.nextSibling?.select?.();
}
// アイテムが隠れていたらスクロールさせる
const {top, bottom} = this.selectedItem.getBoundingClientRect();
const boxRect = this._box.getBoundingClientRect();
if (top < 0) {
this.selectedItem.scrollIntoView({block: 'start'});
} else if (bottom > window.innerHeight) {
this.selectedItem.scrollIntoView({block: 'end'});
} else if (top < boxRect.top) {
this._box.scrollBy(0, top - boxRect.top);
} else if (bottom > boxRect.bottom) {
this._box.scrollBy(0, bottom - boxRect.bottom);
}
if (bottom > window.innerHeight) {
this.selectedItem.scrollIntoView({block: 'bottom'});
} else if (bottom > this._box.getBoundingClientRect().bottom) {
this._box.scrollBy(0, bottom - this._box.getBoundingClientRect().bottom);
}
if (this.selectedItem.disabled) this.selectNext({wrap});
}
selectPrevious({wrap}) {
if (!this.firstSelectableItem) return;
const selectedItem = this.selectedItem;
this.selectedItem?.deselect?.();
if (!selectedItem) {
this.firstSelectableItem?.select?.();
} else if (wrap && this.firstItem === selectedItem) {
this.lastSelectableItem?.select?.();
this._box.scroll(0, this._box.scrollHeight);
} else {
selectedItem.previousSibling?.select?.();
}
// アイテムが隠れていたらスクロールさせる
const {top, bottom} = this.selectedItem.getBoundingClientRect();
const boxRect = this._box.getBoundingClientRect();
if (top < 0) {
this.selectedItem.scrollIntoView({block: 'start'});
} else if (bottom > window.innerHeight) {
this.selectedItem.scrollIntoView({block: 'end'});
} else if (top < boxRect.top) {
this._box.scrollBy(0, top - boxRect.top);
} else if (bottom > boxRect.bottom) {
this._box.scrollBy(0, bottom - boxRect.bottom);
}
if (this.selectedItem.disabled) this.selectPrevious({wrap});
}
position({top, left}) {
this._box.style.top = ${top}px;
this._box.style.left = ${left}px;
this.show();
}
show() {
if (this.items.length === 0) {
this.hide();
return;
}
this.hidden = false;
}
hide() {
this.hidden = true;
}
get _box() {
return this.shadowRoot.getElementById('box');
}
});
import {h} from '../easyDOMgenerator/script.js';
export const suggestContainer = (...params) => h('suggest-container', ...params);
コンテナのCSS
code:container-css.js
export const css = `
ul {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
flex-direction: column;
min-width: 160px;
max-width: 80%;
max-height: 50vh;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 14px;
font-weight: normal;
line-height: 28px;
text-align: left;
background-color: var(--completion-bg, #fff);
border: var(--completion-border, 1px solid rgba(0,0,0,0.15));
border-radius: 4px;
box-shadow: var(--completion-shadow, 0 6px 12px rgba(0,0,0,0.175));
background-clip: padding-box;
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
text-overflow: ellipsis;
}
`;
項目
code:item.js
import {css} from './item-css.js';
customElements.define('suggest-container-item', class extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML =
`<style>${css}</style>
<li>
<a id="body" tabindex="-1">
<img id="icon" hidden></img>
<div id="content">
<div id="title"></div>
<div id="description"></div>
</div>
</a>
</li>`;
this._body = shadow.getElementById('body');
this._icon = shadow.getElementById('icon');
this._title = shadow.getElementById('title');
this._description = shadow.getElementById('description');
this._body.addEventListener('click', e =>{
if (e.metaKey && this._body.href !== '') return;
if (!this._onClick) return;
e.preventDefault();
e.stopPropagation();
this.click(e);
});
}
set({title, link, image, description, onClick}) {
title ? this.setAttribute('title', title) : this.removeAttribute('title');
link ? this.setAttribute('link', link) : this.removeAttribute('link');
image ? this.setAttribute('image', image) : this.removeAttribute('image');
description ?
this.setAttribute('description', description) :
this.removeAttribute('description');
if (!onClick) {
this.classList.add('disabled');
return;
}
if (typeof onClick !== 'function') throw Error('onClick is not a function.');
this._onClick = onClick;
}
get disabled() {
return this.classList.contains('disabled');
}
get isSelected() {
return !this.disabled && this.classList.contains('selected');
}
select() {
if (!this.disabled) this.classList.add('selected');
const element = document.activeElement;
this.focus();
element.focus();
}
deselect() {
this.classList.remove('selected');
this.blur();
}
click(eventHandler) {
this._onClick?.(eventHandler ?? {});
}
static get observedAttributes() {
return 'title', 'link', 'image', 'description',;
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name) {
case 'title':
this._title.textContent = newValue;
return;
case 'image':
const img = this._icon;
if (newValue) {
img.src = newValue;
img.hidden = false;
} else {
img.src = '';
img.hidden = true;
}
return;
case 'description':
this._description.textContent = newValue ?? '';
return;
case 'link':
this._body.href = newValue ?? '';
return;
}
}
});
export const create = (props) => {
const item = document.createElement('suggest-container-item');
item.set(props);
return item;
}
項目のCSS
code:item-css.js
export const css = `
:host(.disabled) {
cursor: not-allowed;
}
a {
display: flex;
padding: 0px 20px;
clear: both;
color: var(--completion-item-text-color, #333);
align-items: center;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-decoration: none;
}
a:hover {
color: var(--completion-item-hover-text-color, #262626);
background-color: var(--completion-item-hover-bg, #f5f5f5);
}
:host(.selected) a{
color: var(--completion-item-hover-text-color, #262626);
background-color: var(--completion-item-hover-bg, #f5f5f5);
outline: 0;
box-shadow: 0 0px 0px 3px rgba(102,175,233,0.6);
border-color: #66afe9;
transition: border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s;
}
img {
height: 1.3em;
margin-right: 3px;
display: inline-block;
}
#content {
width: 100%;
}
:host(description) #content {
min-height: 28px;
}
:host(description) #title {
line-height: 100%;
}
#description {
opacity: 0.5;
font-size: 50%;
line-height: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
`;
#2021-04-09 14:13:36
#2021-04-08 16:53:30