chore(combobox): advanced extension demos github + whatsapp
This commit is contained in:
parent
e9cee0397b
commit
bccef4896c
6 changed files with 1059 additions and 198 deletions
60
packages/combobox/docs/LinkMixin.js
Normal file
60
packages/combobox/docs/LinkMixin.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { dedupeMixin } from '@lion/core';
|
||||
|
||||
/**
|
||||
* @typedef {import('lit-element').PropertyValues } changedProperties
|
||||
*/
|
||||
|
||||
/**
|
||||
* Designed for webcomponents that need to behave like a link.
|
||||
* For instance, comboboxes that have search result options opening a webpage on click.
|
||||
* Using an <a> is not a viable alternative, because:
|
||||
* - no shadow dom (and thus no style encapsulation possibilities)
|
||||
* - we need to extend from LionOption (and we cannot put the anchor inside
|
||||
* the focusable element (LionOption which has [role=option]))
|
||||
*/
|
||||
const LinkMixinImplementation = superclass =>
|
||||
class extends superclass {
|
||||
static get properties() {
|
||||
return {
|
||||
href: String,
|
||||
target: String,
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._nativeAnchor = document.createElement('a');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute('role')) {
|
||||
this.setAttribute('role', 'link');
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.addEventListener('click', this.__navigate);
|
||||
this.addEventListener('keydown', ({ key }) => {
|
||||
if (key === ' ' || key === 'Enter') {
|
||||
this.__navigate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('href')) {
|
||||
this._nativeAnchor.href = this.href;
|
||||
}
|
||||
if (changedProperties.has('target')) {
|
||||
this._nativeAnchor.target = this.target;
|
||||
}
|
||||
}
|
||||
|
||||
__navigate() {
|
||||
this._nativeAnchor.click();
|
||||
}
|
||||
};
|
||||
export const LinkMixin = dedupeMixin(LinkMixinImplementation);
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
```js script
|
||||
import { html } from 'lit-html';
|
||||
import './md-combobox/md-combobox.js';
|
||||
import './md-combobox/md-input.js';
|
||||
import './gh-combobox/gh-combobox.js';
|
||||
import './wa-combobox/wa-combobox.js';
|
||||
|
||||
export default {
|
||||
title: 'Forms/Combobox/Extensions',
|
||||
|
|
@ -21,3 +22,60 @@ export const MaterialDesign = () => html`
|
|||
</md-combobox>
|
||||
`;
|
||||
```
|
||||
|
||||
```js preview-story
|
||||
export const Github = () => html`
|
||||
<gh-combobox name="combo" label="Switch branches/tags">
|
||||
<gh-option href="https://www.github.com" .choiceValue=${'master'} default>master</gh-option>
|
||||
<gh-option .choiceValue=${'develop'}>develop</gh-option>
|
||||
<gh-option .choiceValue=${'release'}>release</gh-option>
|
||||
<gh-option .choiceValue=${'feat/abc'}>feat/abc</gh-option>
|
||||
<gh-option .choiceValue=${'feat/xyz123'}>feat/xyz123</gh-option>
|
||||
</gh-combobox>
|
||||
`;
|
||||
```
|
||||
|
||||
```js preview-story
|
||||
export const Whatsapp = () => html`
|
||||
<wa-combobox name="combo" label="Filter chats">
|
||||
<wa-option
|
||||
title="Barack Obama"
|
||||
text="Yup, let's try that for now👍"
|
||||
time="15:02"
|
||||
is-user-text
|
||||
is-user-text-read
|
||||
image="https://pbs.twimg.com/profile_images/822547732376207360/5g0FC8XX_400x400.jpg"
|
||||
.choiceValue=${'Barack Obama'}
|
||||
></wa-option>
|
||||
<wa-option
|
||||
title="Donald Trump"
|
||||
text="Take care!"
|
||||
time="14:59"
|
||||
is-user-text
|
||||
image="https://pbs.twimg.com/profile_images/874276197357596672/kUuht00m_400x400.jpg"
|
||||
.choiceValue=${'Donald Trump'}
|
||||
></wa-option>
|
||||
<wa-option
|
||||
title="Joe Biden"
|
||||
text="Hehe😅. You too, man, you too..."
|
||||
time="yesterday"
|
||||
image="https://pbs.twimg.com/profile_images/1308769664240160770/AfgzWVE7_400x400.jpg"
|
||||
.choiceValue=${'Joe Biden'}
|
||||
></wa-option>
|
||||
<wa-option
|
||||
title="George W. Bush"
|
||||
time="friday"
|
||||
text="You bet I will. Let's catch up soon!"
|
||||
image="https://pbs.twimg.com/profile_images/828483922266877954/ljYUWUCu_400x400.jpg"
|
||||
.choiceValue=${'George W. Bush'}
|
||||
></wa-option>
|
||||
<wa-option
|
||||
title="Bill Clinton"
|
||||
time="thursday"
|
||||
text="Dude...😂 😂 😂"
|
||||
image="https://pbs.twimg.com/profile_images/1239440892664086529/iY0Z83Dr_400x400.jpg"
|
||||
.choiceValue=${'Bill Clinton'}
|
||||
></wa-option>
|
||||
</wa-combobox>
|
||||
`;
|
||||
```
|
||||
|
|
|
|||
69
packages/combobox/docs/gh-combobox/gh-button.js
Normal file
69
packages/combobox/docs/gh-combobox/gh-button.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { css, html } from '@lion/core';
|
||||
import { LionButton } from '@lion/button';
|
||||
|
||||
export class GhButton extends LionButton {
|
||||
static get properties() {
|
||||
return {
|
||||
value: String,
|
||||
};
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
outline: none;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
color: #24292e;
|
||||
background-color: #fafbfc;
|
||||
border-color: rgba(27, 31, 35, 0.15);
|
||||
box-shadow: 0 1px 0 rgba(27, 31, 35, 0.04), inset 0 1px 0 hsla(0, 0%, 100%, 0.25);
|
||||
transition: background-color 0.2s cubic-bezier(0.3, 0, 0.5, 1);
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background-color: #f3f4f6;
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
:host ::slotted([slot='before']) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: this doesn't have to be light dom anymore in LionButton,
|
||||
* just spawning a hidden native button on submit would be enough
|
||||
*/
|
||||
:host ::slotted(button) {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <slot name="before"></slot>
|
||||
${this.value}
|
||||
<slot name="after"></slot>
|
||||
<slot name="_button"></slot>`;
|
||||
}
|
||||
}
|
||||
customElements.define('gh-button', GhButton);
|
||||
503
packages/combobox/docs/gh-combobox/gh-combobox.js
Normal file
503
packages/combobox/docs/gh-combobox/gh-combobox.js
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { css, html } from '@lion/core';
|
||||
import { LionOption } from '@lion/listbox';
|
||||
import { renderLitAsNode } from '@lion/helpers';
|
||||
// import { withModalDialogConfig } from '@lion/overlays';
|
||||
import { LionCombobox } from '../../src/LionCombobox.js';
|
||||
import './gh-button.js';
|
||||
|
||||
export class GhOption extends LionOption {
|
||||
static get properties() {
|
||||
return {
|
||||
category: String,
|
||||
default: { type: Boolean, reflect: true },
|
||||
};
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
color: #24292e;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 544px) {
|
||||
:host {
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
:host([checked]) {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
:host([active]),
|
||||
:host([focused]) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.gh-check-icon {
|
||||
visibility: hidden;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
:host([checked]) .gh-check-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.gh-default-badge {
|
||||
visibility: hidden;
|
||||
|
||||
display: inline-block;
|
||||
padding: 0 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
border-radius: 2em;
|
||||
background-color: initial !important;
|
||||
border: 1px solid #e1e4e8;
|
||||
color: #586069;
|
||||
border-color: #e1e4e8;
|
||||
}
|
||||
|
||||
:host([default]) .gh-default-badge {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.gh-content {
|
||||
flex: 1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<svg
|
||||
class="gh-check-icon"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="gh-content"><slot></slot></span>
|
||||
<span class="gh-default-badge">default</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('gh-option', GhOption);
|
||||
|
||||
export class GhCombobox extends LionCombobox {
|
||||
static get properties() {
|
||||
return {
|
||||
category: { type: String },
|
||||
isDesktop: { type: Boolean, reflect: true, attribute: 'is-desktop' },
|
||||
};
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
/** @configure LionCombobox */
|
||||
|
||||
:host {
|
||||
font-family: apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif,
|
||||
Apple Color Emoji, Segoe UI Emoji;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group__container {
|
||||
display: flex;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
* > ::slotted([role='listbox']) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
* > ::slotted([slot='input']) {
|
||||
padding: 5px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #24292e;
|
||||
vertical-align: middle;
|
||||
background-color: #fff;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(225, 228, 232, 0.2);
|
||||
}
|
||||
|
||||
:host([is-desktop]) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:host([is-desktop]) ::slotted([slot='input']) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:host([focused]) ::slotted([slot='input']) {
|
||||
border-color: #0366d6;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.3);
|
||||
}
|
||||
|
||||
.gh-combobox {
|
||||
height: auto;
|
||||
max-height: 480px;
|
||||
margin-top: 8px;
|
||||
|
||||
position: relative;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
max-height: 66%;
|
||||
margin: auto 0;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 18px rgba(27, 31, 35, 0.4);
|
||||
/* animation: SelectMenu-modal-animation 0.12s cubic-bezier(0, 0.1, 0.1, 1) backwards; */
|
||||
}
|
||||
|
||||
:host([is-desktop]) .gh-combobox {
|
||||
width: 300px;
|
||||
height: auto;
|
||||
max-height: 480px;
|
||||
margin: 8px 0 16px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(149, 157, 165, 0.2);
|
||||
/* animation-name: SelectMenu-modal-animation--sm; */
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/** @enhance LionCombobox */
|
||||
|
||||
.gh-categories {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
box-shadow: inset 0 -1px 0 #eaecef;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
:host([is-desktop]) .gh-categories {
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
|
||||
.gh-categories__btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6a737d;
|
||||
text-align: center;
|
||||
background-color: initial;
|
||||
border: 0;
|
||||
box-shadow: inset 0 -1px 0 #eaecef;
|
||||
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
outline: none;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gh-categories__btn:focus {
|
||||
background-color: #dbedff;
|
||||
}
|
||||
|
||||
:host([is-desktop]) .gh-categories__btn {
|
||||
flex: none;
|
||||
padding: 4px 16px;
|
||||
border: solid transparent;
|
||||
border-width: 1px 1px 0;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.gh-categories__btn[aria-pressed='true'] {
|
||||
z-index: 1;
|
||||
color: #24292e;
|
||||
cursor: default;
|
||||
box-shadow: 0 0 0 1px #eaecef;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:host([is-desktop]) .gh-categories__btn {
|
||||
flex: none;
|
||||
}
|
||||
:host([is-desktop]) .gh-categories__btn[aria-pressed='true'] {
|
||||
border-color: #eaecef;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gh-section-wrapper {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
|
||||
:host([is-desktop]) .gh-section-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override LionCombobox put all content in an overlay
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
render() {
|
||||
return html`
|
||||
<slot name="selection-display"></slot>
|
||||
<div id="overlay-content-node-wrapper" role="dialog">
|
||||
<div class="gh-combobox">
|
||||
<div class="form-field__group-one">
|
||||
<div class="gh-section-wrapper">${this._groupOneTemplate()}</div>
|
||||
</div>
|
||||
<div class="form-field__group-two">
|
||||
<div class="gh-section-wrapper">${this._groupTwoTemplate()}</div>
|
||||
<div class="gh-categories" @click="${this.__handleCategory}">
|
||||
<button type="button" data-category="branches" class="gh-categories__btn">
|
||||
Branches
|
||||
</button>
|
||||
<button type="button" data-category="tags" class="gh-categories__btn">Tags</button>
|
||||
</div>
|
||||
<slot name="listbox"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot id="options-outlet"></slot>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper with combobox role for the text input that the end user controls the listbox with.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
get _comboboxNode() {
|
||||
if (this.__comboboxNode) {
|
||||
return this.__comboboxNode;
|
||||
}
|
||||
const slot = this.querySelector('[slot="input"]');
|
||||
if (slot) {
|
||||
this.__comboboxNode = slot;
|
||||
return slot;
|
||||
}
|
||||
const slot2 = this._overlayCtrl?.contentWrapperNode.querySelector('[slot="input"]');
|
||||
if (slot2) {
|
||||
this.__comboboxNode = slot2;
|
||||
return slot2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override LionCombobox: remove selection-display (place it higher up)
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_inputGroupInputTemplate() {
|
||||
return html`
|
||||
<div class="input-group__input">
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override LionCombobox: restore to values FormControlMixin
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_groupTwoTemplate() {
|
||||
return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `;
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return {
|
||||
...super.slots,
|
||||
'selection-display': () => {
|
||||
return renderLitAsNode(html` <gh-button>
|
||||
<svg
|
||||
slot="before"
|
||||
text="gray"
|
||||
height="16"
|
||||
class="octicon octicon-git-branch text-gray"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
<span slot="after"> </span>
|
||||
</gh-button>`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get _overlayInvokerNode() {
|
||||
return this.querySelector('[slot="selection-display"]');
|
||||
}
|
||||
|
||||
get _overlayReferenceNode() {
|
||||
return this._overlayInvokerNode;
|
||||
}
|
||||
|
||||
get _categoryButtons() {
|
||||
return Array.from(this.shadowRoot.querySelectorAll('.gh-categories__btn[data-category]'));
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.showAllOnEmpty = true;
|
||||
|
||||
/** @type {'branches'|'tags'} */
|
||||
this.category = 'branches';
|
||||
|
||||
this.selectionFollowsFocus = false;
|
||||
|
||||
// Capture mobile OverlayConfig
|
||||
this.__mobileDropdownComboConfig = this.config;
|
||||
}
|
||||
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
const mql = window.matchMedia('(min-width: 544px)');
|
||||
this.isDesktop = mql.matches;
|
||||
mql.addListener(({ matches }) => {
|
||||
this.isDesktop = matches;
|
||||
});
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('category')) {
|
||||
const cat = this.category;
|
||||
this._categoryButtons.forEach(btn => {
|
||||
btn.setAttribute(
|
||||
'aria-pressed',
|
||||
btn.getAttribute('data-category') === cat ? 'true' : 'false',
|
||||
);
|
||||
});
|
||||
|
||||
this._inputNode.placeholder =
|
||||
cat === 'branches' ? 'Find or create a branch...' : 'Find a tag';
|
||||
|
||||
this._handleAutocompletion();
|
||||
}
|
||||
|
||||
if (changedProperties.has('opened')) {
|
||||
// eslint-disable-next-line no-shadow
|
||||
this._selectionDisplayNode.value = this.modelValue || 'Choose a value...';
|
||||
|
||||
if (this.opened) {
|
||||
setTimeout(() => {
|
||||
this._inputNode.focus();
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this._selectionDisplayNode.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('isDesktop')) {
|
||||
// this.config = this.isDesktop ? this.__mobileDropdownComboConfig : withModalDialogConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @enhance LionCombobox
|
||||
// * @param {*} option
|
||||
// * @param {...any} args
|
||||
// */
|
||||
// matchCondition(option, ...args) {
|
||||
// return super.matchCondition(option, ...args) && option.category === this.category;
|
||||
// }
|
||||
|
||||
// _defineOverlayConfig() {
|
||||
// // temp
|
||||
// return { ...super._defineOverlayConfig(), hidesOnOutsideClick: false };
|
||||
// }
|
||||
|
||||
__handleCategory(ev) {
|
||||
this.category = ev.target.getAttribute('data-category');
|
||||
}
|
||||
|
||||
// TODO: overrides below are not safe for override and should be made configurable in Combobox
|
||||
// basically it should be possible te create a combobox without an overlay
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_textboxOnKeydown() {
|
||||
// if (ev.key === 'Tab') {
|
||||
// this.opened = false;
|
||||
// }
|
||||
this.__hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @enhance OverlayMixin
|
||||
*/
|
||||
_setupOpenCloseListeners() {
|
||||
super._setupOpenCloseListeners();
|
||||
this.__toggleOverlay = this.__toggleOverlay.bind(this);
|
||||
this._overlayInvokerNode.addEventListener('click', this.__toggleOverlay);
|
||||
}
|
||||
|
||||
__toggleOverlay() {
|
||||
this.opened = !this.opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* @enhance OverlayMixin
|
||||
*/
|
||||
_teardownOpenCloseListeners() {
|
||||
super._teardownOpenCloseListeners();
|
||||
this._overlayInvokerNode.removeEventListener('click', this.__toggleOverlay);
|
||||
}
|
||||
}
|
||||
customElements.define('gh-combobox', GhCombobox);
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
// eslint-disable-next-line max-classes-per-file
|
||||
import { LitElement, html, css, nothing } from '@lion/core';
|
||||
|
||||
/**
|
||||
* Disclaimer: this is just an example component demoing the selection display of LionCombobox
|
||||
* It needs an 'a11y plan' and tests before it could be released
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders the wrapper containing the textbox that triggers the listbox with filtered options.
|
||||
* Optionally, shows 'chips' that indicate the selection.
|
||||
* Should be considered an internal/protected web component to be used in conjunction with
|
||||
* LionCombobox
|
||||
*
|
||||
*/
|
||||
export class LionComboboxSelectionDisplay extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
comboboxElement: Object,
|
||||
/**
|
||||
* Can be used to visually indicate the next
|
||||
*/
|
||||
removeChipOnNextBackspace: Boolean,
|
||||
selectedElements: Array,
|
||||
};
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.combobox__selection {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.combobox__input {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selection-chip {
|
||||
border-radius: 4px;
|
||||
background-color: #eee;
|
||||
padding: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.selection-chip--highlighted {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
* > ::slotted([slot='_textbox']) {
|
||||
outline: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @configure FocusMixin
|
||||
*/
|
||||
get _inputNode() {
|
||||
return this.comboboxElement._inputNode;
|
||||
}
|
||||
|
||||
_computeSelectedElements() {
|
||||
const { formElements, checkedIndex } = /** @type {LionCombobox} */ (this.comboboxElement);
|
||||
const checkedIndexes = Array.isArray(checkedIndex) ? checkedIndex : [checkedIndex];
|
||||
return formElements.filter((_, i) => checkedIndexes.includes(i));
|
||||
}
|
||||
|
||||
get multipleChoice() {
|
||||
return this.comboboxElement?.multipleChoice;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/** @type {EventListener} */
|
||||
this.__textboxOnKeyup = this.__textboxOnKeyup.bind(this);
|
||||
/** @type {EventListener} */
|
||||
this.__restoreBackspace = this.__restoreBackspace.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('lit-element').PropertyValues } changedProperties
|
||||
*/
|
||||
firstUpdated(changedProperties) {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
if (this.multipleChoice) {
|
||||
this._inputNode.addEventListener('keyup', this.__textboxOnKeyup);
|
||||
this._inputNode.addEventListener('focusout', this.__restoreBackspace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('lit-element').PropertyValues } changedProperties
|
||||
*/
|
||||
onComboboxElementUpdated(changedProperties) {
|
||||
if (changedProperties.has('modelValue')) {
|
||||
this.selectedElements = this._computeSelectedElements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whenever selectedElements are updated, makes sure that latest added elements
|
||||
* are shown latest, and deleted elements respect existing order of chips.
|
||||
*/
|
||||
__reorderChips() {
|
||||
const { selectedElements } = this;
|
||||
if (this.__prevSelectedEls) {
|
||||
const addedEls = selectedElements.filter(e => !this.__prevSelectedEls.includes(e));
|
||||
const deletedEls = this.__prevSelectedEls.filter(e => !selectedElements.includes(e));
|
||||
if (addedEls.length) {
|
||||
this.selectedElements = [...this.__prevSelectedEls, ...addedEls];
|
||||
} else if (deletedEls.length) {
|
||||
deletedEls.forEach(delEl => {
|
||||
this.__prevSelectedEls.splice(this.__prevSelectedEls.indexOf(delEl), 1);
|
||||
});
|
||||
this.selectedElements = this.__prevSelectedEls;
|
||||
}
|
||||
}
|
||||
this.__prevSelectedEls = this.selectedElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("@lion/listbox").LionOption} option
|
||||
* @param {boolean} highlight
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_selectedElementTemplate(option, highlight) {
|
||||
return html`
|
||||
<span class="selection-chip ${highlight ? 'selection-chip--highlighted' : ''}">
|
||||
${option.value}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
_selectedElementsTemplate() {
|
||||
if (!this.multipleChoice) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="combobox__selection">
|
||||
${this.selectedElements.map((option, i) => {
|
||||
const highlight = Boolean(
|
||||
this.removeChipOnNextBackspace && i === this.selectedElements.length - 1,
|
||||
);
|
||||
return this._selectedElementTemplate(option, highlight);
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` ${this._selectedElementsTemplate()} `;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ key: string; }} ev
|
||||
*/
|
||||
__textboxOnKeyup(ev) {
|
||||
// Why we handle here and not in LionComboboxInvoker:
|
||||
// All selectedElements state truth should be kept here and should not go back
|
||||
// and forth.
|
||||
if (ev.key === 'Backspace') {
|
||||
if (!this._inputNode.value) {
|
||||
if (this.removeChipOnNextBackspace) {
|
||||
this.selectedElements[this.selectedElements.length - 1].checked = false;
|
||||
}
|
||||
this.removeChipOnNextBackspace = true;
|
||||
}
|
||||
} else {
|
||||
this.removeChipOnNextBackspace = false;
|
||||
}
|
||||
|
||||
// TODO: move to LionCombobox
|
||||
if (ev.key === 'Escape') {
|
||||
this._inputNode.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
__restoreBackspace() {
|
||||
this.removeChipOnNextBackspace = false;
|
||||
}
|
||||
}
|
||||
customElements.define('lion-combobox-selection-display', LionComboboxSelectionDisplay);
|
||||
368
packages/combobox/docs/wa-combobox/wa-combobox.js
Normal file
368
packages/combobox/docs/wa-combobox/wa-combobox.js
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import { html, css } from '@lion/core';
|
||||
import { renderLitAsNode } from '@lion/helpers';
|
||||
import { LionOption } from '@lion/listbox';
|
||||
import { LionCombobox } from '../../src/LionCombobox.js';
|
||||
|
||||
class WaOption extends LionOption {
|
||||
static get properties() {
|
||||
return {
|
||||
title: String,
|
||||
text: String,
|
||||
time: String,
|
||||
image: String,
|
||||
isUserText: { attribute: 'is-user-text', reflect: true, type: Boolean },
|
||||
isUserTextRead: { attribute: 'is-user-text-read', reflect: true, type: Boolean },
|
||||
};
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
--background-default: white;
|
||||
--background-default-active: gray;
|
||||
--secondary: #777;
|
||||
--secondary-lighter: #aaa;
|
||||
--chatlist-icon: #aaa;
|
||||
background-color: var(--background-default);
|
||||
cursor: pointer;
|
||||
color: rgb(74, 74, 74);
|
||||
padding: 0;
|
||||
transition: max-height 0.4s ease, opacity 0.3s ease;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
:host([checked]) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
:host(:hover) {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
.wa-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 72px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.wa-option__image {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
margin-top: -1px;
|
||||
padding: 0 15px 0 13px;
|
||||
}
|
||||
|
||||
.wa-option__image-inner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--avatar-background);
|
||||
border-radius: 50%;
|
||||
height: 49px;
|
||||
width: 49px;
|
||||
}
|
||||
|
||||
.wa-option__image-inner img,
|
||||
.wa-option__image-inner svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wa-option__image-inner-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wa-option__content {
|
||||
display: flex;
|
||||
flex-basis: auto;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.wa-option__content-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wa-option__content-row1-title {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
color: var(--primary-strong);
|
||||
font-weight: 400;
|
||||
font-size: 17px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.wa-option__content-row1-time {
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-left: 6px;
|
||||
margin-top: 3px;
|
||||
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
color: var(--secondary-lighter);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wa-option__content-row2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
color: var(--secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
margin-top: 2px;
|
||||
/* color: var(--secondary-stronger); */
|
||||
}
|
||||
|
||||
.wa-option__content-row2-text {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wa-option__content-row2-text-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wa-option__content-row2-text-inner-icon {
|
||||
display: none;
|
||||
flex: none;
|
||||
color: var(--chatlist-icon);
|
||||
vertical-align: top;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
:host([is-user-text]) .wa-option__content-row2-text-inner-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:host([is-user-text-read]) .wa-option__content-row2-text-inner-icon {
|
||||
color: lightblue;
|
||||
}
|
||||
/*
|
||||
.wa-option__content-row2-menu {
|
||||
} */
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="wa-option">
|
||||
<div class="wa-option__image">
|
||||
<div class="wa-option__image-inner">
|
||||
<img
|
||||
src="${this.image}"
|
||||
alt=""
|
||||
draggable="false"
|
||||
class="_2goTk _1Jdop _3Whw5"
|
||||
style="visibility: visible;"
|
||||
/>
|
||||
${this.image
|
||||
? ''
|
||||
: html`<div class="wa-option__image-inner-inner">
|
||||
<span data-testid="default-user" data-icon="default-user" class="">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 212 212"
|
||||
width="212"
|
||||
height="212"
|
||||
>
|
||||
<path
|
||||
fill="#DFE5E7"
|
||||
class="background"
|
||||
d="M106.251.5C164.653.5 212 47.846 212 106.25S164.653 212 106.25 212C47.846 212 .5 164.654.5 106.25S47.846.5 106.251.5z"
|
||||
></path>
|
||||
<path
|
||||
fill="#FFF"
|
||||
class="primary"
|
||||
d="M173.561 171.615a62.767 62.767 0 0 0-2.065-2.955 67.7 67.7 0 0 0-2.608-3.299 70.112 70.112 0 0 0-3.184-3.527 71.097 71.097 0 0 0-5.924-5.47 72.458 72.458 0 0 0-10.204-7.026 75.2 75.2 0 0 0-5.98-3.055c-.062-.028-.118-.059-.18-.087-9.792-4.44-22.106-7.529-37.416-7.529s-27.624 3.089-37.416 7.529c-.338.153-.653.318-.985.474a75.37 75.37 0 0 0-6.229 3.298 72.589 72.589 0 0 0-9.15 6.395 71.243 71.243 0 0 0-5.924 5.47 70.064 70.064 0 0 0-3.184 3.527 67.142 67.142 0 0 0-2.609 3.299 63.292 63.292 0 0 0-2.065 2.955 56.33 56.33 0 0 0-1.447 2.324c-.033.056-.073.119-.104.174a47.92 47.92 0 0 0-1.07 1.926c-.559 1.068-.818 1.678-.818 1.678v.398c18.285 17.927 43.322 28.985 70.945 28.985 27.678 0 52.761-11.103 71.055-29.095v-.289s-.619-1.45-1.992-3.778a58.346 58.346 0 0 0-1.446-2.322zM106.002 125.5c2.645 0 5.212-.253 7.68-.737a38.272 38.272 0 0 0 3.624-.896 37.124 37.124 0 0 0 5.12-1.958 36.307 36.307 0 0 0 6.15-3.67 35.923 35.923 0 0 0 9.489-10.48 36.558 36.558 0 0 0 2.422-4.84 37.051 37.051 0 0 0 1.716-5.25c.299-1.208.542-2.443.725-3.701.275-1.887.417-3.827.417-5.811s-.142-3.925-.417-5.811a38.734 38.734 0 0 0-1.215-5.494 36.68 36.68 0 0 0-3.648-8.298 35.923 35.923 0 0 0-9.489-10.48 36.347 36.347 0 0 0-6.15-3.67 37.124 37.124 0 0 0-5.12-1.958 37.67 37.67 0 0 0-3.624-.896 39.875 39.875 0 0 0-7.68-.737c-21.162 0-37.345 16.183-37.345 37.345 0 21.159 16.183 37.342 37.345 37.342z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wa-option__content">
|
||||
<div class="wa-option__content-row1">
|
||||
<div class="wa-option__content-row1-title">
|
||||
<span class="_357i8">
|
||||
<span dir="auto" title="${this.title}" class="_3ko75 _5h6Y_ _3Whw5">
|
||||
${this.title}
|
||||
</span>
|
||||
<div class="_3XFan"></div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wa-option__content-row1-time">${this.time}</div>
|
||||
</div>
|
||||
<div class="wa-option__content-row2">
|
||||
<div class="wa-option__content-row2-text">
|
||||
<span class="wa-option__content-row2-text-inner" title="${this.text}">
|
||||
<div class="wa-option__content-row2-text-inner-icon">
|
||||
<span data-testid="status-dblcheck" data-icon="status-dblcheck" class="">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 18 18"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.394 5.035l-.57-.444a.434.434 0 0 0-.609.076l-6.39 8.198a.38.38 0 0 1-.577.039l-.427-.388a.381.381 0 0 0-.578.038l-.451.576a.497.497 0 0 0 .043.645l1.575 1.51a.38.38 0 0 0 .577-.039l7.483-9.602a.436.436 0 0 0-.076-.609zm-4.892 0l-.57-.444a.434.434 0 0 0-.609.076l-6.39 8.198a.38.38 0 0 1-.577.039l-2.614-2.556a.435.435 0 0 0-.614.007l-.505.516a.435.435 0 0 0 .007.614l3.887 3.8a.38.38 0 0 0 .577-.039l7.483-9.602a.435.435 0 0 0-.075-.609z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span dir="ltr" class="_3ko75 _5h6Y_ _3Whw5">${this.text}</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="wa-option__content-row2-menu"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @configure LionCombobox
|
||||
* @param {string} matchingString
|
||||
*/
|
||||
onFilterMatch(matchingString) {
|
||||
this.__originalTitle = this.title;
|
||||
const newInnerHTML = this.title.replace(new RegExp(`(${matchingString})`, 'i'), `<b>$1</b>`);
|
||||
const helperNode = document.createElement('div');
|
||||
// For Safari, we need to add a label to the element
|
||||
helperNode.innerHTML = `<span aria-label="${this.title}">${newInnerHTML}</span>`;
|
||||
[this.title] = helperNode.children;
|
||||
this.style.cssText = `
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @configure LionCombobox
|
||||
* @param {string} [curValue]
|
||||
* @param {string} [prevValue]
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onFilterUnmatch() {
|
||||
if (this.__originalTitle) {
|
||||
this.title = this.__originalTitle;
|
||||
}
|
||||
this.style.cssText = `
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('wa-option', WaOption);
|
||||
|
||||
class WaCombobox extends LionCombobox {
|
||||
static get styles() {
|
||||
return [
|
||||
super.styles,
|
||||
css`
|
||||
:host {
|
||||
font-family: SF Pro Text, SF Pro Icons, system, -apple-system, system-ui,
|
||||
BlinkMacSystemFont, Helvetica Neue, Helvetica, Lucida Grande, Kohinoor Devanagari,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.input-group__container {
|
||||
display: flex;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
* > ::slotted([role='listbox']) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
* > ::slotted([slot='input']) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
padding: 15px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.input-group__prefix {
|
||||
margin-right: 20px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-group__container {
|
||||
border-radius: 18px;
|
||||
background: white;
|
||||
padding: 7px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/** Undo Popper */
|
||||
#overlay-content-node-wrapper {
|
||||
position: static !important;
|
||||
width: auto !important;
|
||||
transform: none !important;
|
||||
|
||||
/* height: 300px;
|
||||
overflow: scroll; */
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
get slots() {
|
||||
return {
|
||||
...super.slots,
|
||||
prefix: () =>
|
||||
renderLitAsNode(
|
||||
html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15.009 13.805h-.636l-.22-.219a5.184 5.184 0 0 0 1.256-3.386 5.207 5.207 0 1 0-5.207 5.208 5.183 5.183 0 0 0 3.385-1.255l.221.22v.635l4.004 3.999 1.194-1.195-3.997-4.007zm-4.808 0a3.605 3.605 0 1 1 0-7.21 3.605 3.605 0 0 1 0 7.21z"
|
||||
></path>
|
||||
</svg>`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/** @configure OverlayMixin */
|
||||
this.opened = true;
|
||||
/** @configure LionCombobox */
|
||||
this.showAllOnEmpty = true;
|
||||
/** @configure LionCombobox */
|
||||
this.rotateKeyboardNavigation = false;
|
||||
}
|
||||
}
|
||||
customElements.define('wa-combobox', WaCombobox);
|
||||
Loading…
Reference in a new issue