|
|
@@ -0,0 +1,237 @@
|
|
|
+/** 工作台成员浏览选择器;仅派发原有 me-change,不切换登录账号。 */
|
|
|
+import { LitElement, html, css, nothing } from 'lit';
|
|
|
+import { useTokens, define } from './base.js';
|
|
|
+import { icon } from './icons.js';
|
|
|
+
|
|
|
+export class KfMemberPicker extends LitElement {
|
|
|
+ static properties = {
|
|
|
+ members: { type: Array },
|
|
|
+ value: { type: String },
|
|
|
+ summary: { type: String },
|
|
|
+ _open: { state: true },
|
|
|
+ _query: { state: true },
|
|
|
+ _active: { state: true },
|
|
|
+ };
|
|
|
+
|
|
|
+ constructor() {
|
|
|
+ super();
|
|
|
+ this.members = [];
|
|
|
+ this.value = '';
|
|
|
+ this.summary = '';
|
|
|
+ this._open = false;
|
|
|
+ this._query = '';
|
|
|
+ this._active = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ connectedCallback() {
|
|
|
+ super.connectedCallback();
|
|
|
+ useTokens(this);
|
|
|
+ window.addEventListener('resize', this.#positionMenu);
|
|
|
+ window.addEventListener('scroll', this.#onScroll, true);
|
|
|
+ window.visualViewport?.addEventListener('resize', this.#positionMenu);
|
|
|
+ }
|
|
|
+
|
|
|
+ disconnectedCallback() {
|
|
|
+ window.removeEventListener('resize', this.#positionMenu);
|
|
|
+ window.removeEventListener('scroll', this.#onScroll, true);
|
|
|
+ window.visualViewport?.removeEventListener('resize', this.#positionMenu);
|
|
|
+ super.disconnectedCallback();
|
|
|
+ }
|
|
|
+
|
|
|
+ static styles = css`
|
|
|
+ :host { display: block; justify-self: start; min-width: 0; max-width: 100%; }
|
|
|
+ button, input { font-family: var(--kf-font); }
|
|
|
+ button { cursor: pointer; }
|
|
|
+ .member-picker { display: flex; align-items: center; gap: 12px; padding: 6px 8px; margin: -6px -8px;
|
|
|
+ max-width: calc(100% + 16px); min-height: 54px; box-sizing: border-box; border: 1px solid transparent;
|
|
|
+ border-radius: 10px; background: transparent; color: var(--kf-ink); text-align: left; transition: var(--kf-t-colors); }
|
|
|
+ .member-picker:hover, .member-picker[aria-expanded="true"] { background: var(--kf-surface-btn); border-color: var(--kf-line-soft); }
|
|
|
+ .member-picker:focus-visible, .search-clear:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
|
|
|
+ .avatar { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; flex: none;
|
|
|
+ background: var(--kf-ink); color: #fff; font-size: 20px; line-height: 1; font-weight: 600; }
|
|
|
+ .avatar.all { background: var(--kf-fill); color: var(--kf-ink-3); }
|
|
|
+ .me-title { display: grid; gap: 2px; min-width: 0; }
|
|
|
+ .member-name { display: flex; align-items: center; gap: 8px; font-size: 16px; line-height: 1.4; font-weight: 600; }
|
|
|
+ .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
+ .chevron { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px;
|
|
|
+ color: var(--kf-ink-3); background: var(--kf-fill); flex: none; transition: var(--kf-t-colors); }
|
|
|
+ .member-picker[aria-expanded="true"] .chevron { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
|
|
|
+ .chevron .i { transition: transform 160ms ease; }
|
|
|
+ .member-picker[aria-expanded="true"] .chevron .i { transform: rotate(180deg); }
|
|
|
+ .sub { font-size: 12px; font-weight: 400; line-height: 1.5; color: var(--kf-ink-3); }
|
|
|
+
|
|
|
+ /* top layer 避免卡片的 overflow 裁切列表,位置按实际可用视口计算。 */
|
|
|
+ .member-menu { position: fixed; inset: auto; margin: 0; padding: 8px; width: 328px; box-sizing: border-box;
|
|
|
+ max-width: calc(100vw - 24px); max-height: 440px; border: 1px solid var(--kf-line-soft); border-radius: 14px;
|
|
|
+ background: var(--kf-surface); color: var(--kf-ink); overflow: hidden;
|
|
|
+ box-shadow: 0 16px 48px rgba(24, 24, 39, .13), 0 3px 10px rgba(24, 24, 39, .06); }
|
|
|
+ .member-menu:popover-open { display: flex; flex-direction: column; }
|
|
|
+ .menu-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 8px 12px; flex: none; }
|
|
|
+ .menu-heading strong { font-size: 14px; font-weight: 600; }
|
|
|
+ .menu-heading small { font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
|
|
|
+ .search { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; margin: 0 4px 8px;
|
|
|
+ border: 1px solid var(--kf-line-soft); border-radius: 8px; background: var(--kf-surface-btn); color: var(--kf-ink-3); flex: none; }
|
|
|
+ .search:focus-within { border-color: color-mix(in srgb, var(--kf-accent) 50%, transparent); background: var(--kf-surface);
|
|
|
+ box-shadow: 0 0 0 3px var(--kf-accent-soft); }
|
|
|
+ .search input { width: 100%; min-width: 0; height: 38px; padding: 0; border: 0; outline: none;
|
|
|
+ background: transparent; color: var(--kf-ink); font-size: 14px; }
|
|
|
+ .search input::placeholder { color: var(--kf-ink-3); }
|
|
|
+ .search .i { flex: none; }
|
|
|
+ .search-clear { display: grid; place-items: center; flex: none; width: 26px; height: 26px; padding: 0;
|
|
|
+ border: 0; border-radius: 5px; background: transparent; color: var(--kf-ink-3); }
|
|
|
+ .search-clear:hover { background: var(--kf-fill); color: var(--kf-ink); }
|
|
|
+ .options { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: auto; padding: 2px;
|
|
|
+ scrollbar-width: thin; scrollbar-color: var(--kf-line-strong) transparent; }
|
|
|
+ .option { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 46px; padding: 7px 10px;
|
|
|
+ box-sizing: border-box; border: 0; border-radius: 8px; background: transparent; color: var(--kf-ink); text-align: left;
|
|
|
+ transition: var(--kf-t-colors); }
|
|
|
+ .option + .option { margin-top: 2px; }
|
|
|
+ .option:hover, .option.active { background: var(--kf-surface-btn); }
|
|
|
+ .option[aria-selected="true"] { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
|
|
|
+ .option[aria-selected="true"].active { background: color-mix(in srgb, var(--kf-accent) 14%, var(--kf-surface)); }
|
|
|
+ .option-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; flex: none;
|
|
|
+ background: var(--kf-fill); color: var(--kf-ink-2); }
|
|
|
+ .option[aria-selected="true"] .option-icon { background: color-mix(in srgb, var(--kf-accent) 14%, var(--kf-surface)); color: var(--kf-accent-ink); }
|
|
|
+ .option .name { font-size: 14px; font-weight: 500; }
|
|
|
+ .option .group { margin-left: auto; padding-left: 6px; color: var(--kf-ink-3); font-size: 12px; white-space: nowrap; }
|
|
|
+ .option .check { display: grid; place-items: center; width: 18px; margin-left: auto; color: var(--kf-accent-ink); flex: none; }
|
|
|
+ .option .group + .check { margin-left: 0; }
|
|
|
+ .all-option { margin-bottom: 6px; }
|
|
|
+ .no-match { margin: 8px 10px 12px; color: var(--kf-ink-3); font-size: 13px; line-height: 1.6; text-align: center; }
|
|
|
+ @media (max-width: 767px) { .search input { font-size: 16px; } .option { min-height: 48px; } }
|
|
|
+ @media (prefers-reduced-motion: reduce) { .chevron .i { transition: none; } }
|
|
|
+ `;
|
|
|
+
|
|
|
+ get #options() {
|
|
|
+ const query = this._query.trim().toLocaleLowerCase();
|
|
|
+ return [{ name: '', label: '全部成员' }, ...this.members.filter(member =>
|
|
|
+ !query || `${member.name} ${member.groupId || ''}`.toLocaleLowerCase().includes(query)
|
|
|
+ ).map(member => ({ ...member, label: member.name }))];
|
|
|
+ }
|
|
|
+
|
|
|
+ #onScroll = event => {
|
|
|
+ const menu = this.renderRoot.querySelector('.member-menu');
|
|
|
+ if (menu?.matches(':popover-open') && !event.composedPath().includes(menu)) this.#closeMenu(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ #positionMenu = () => {
|
|
|
+ const menu = this.renderRoot.querySelector('.member-menu');
|
|
|
+ if (!menu?.matches(':popover-open')) return;
|
|
|
+ const anchor = this.renderRoot.querySelector('.member-picker').getBoundingClientRect();
|
|
|
+ const viewport = window.visualViewport;
|
|
|
+ const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
|
|
|
+ const width = viewport?.width || document.documentElement.clientWidth, height = viewport?.height || innerHeight;
|
|
|
+ const menuWidth = Math.min(328, width - 24);
|
|
|
+ const below = top + height - anchor.bottom - 20, above = anchor.top - top - 20;
|
|
|
+ const upwards = below < 260 && above > below;
|
|
|
+ menu.style.width = `${menuWidth}px`;
|
|
|
+ menu.style.maxHeight = `${Math.min(440, height - 24, Math.max(240, upwards ? above : below))}px`;
|
|
|
+ menu.style.left = `${Math.max(left + 12, Math.min(anchor.left, left + width - menuWidth - 12))}px`;
|
|
|
+ const menuHeight = menu.getBoundingClientRect().height;
|
|
|
+ const y = upwards ? anchor.top - menuHeight - 8 : anchor.bottom + 8;
|
|
|
+ menu.style.top = `${Math.max(top + 12, Math.min(y, top + height - menuHeight - 12))}px`;
|
|
|
+ this.#scrollActive();
|
|
|
+ };
|
|
|
+
|
|
|
+ async #openMenu() {
|
|
|
+ this._query = '';
|
|
|
+ this._active = Math.max(0, this.#options.findIndex(option => option.name === this.value));
|
|
|
+ await this.updateComplete;
|
|
|
+ if (!this.isConnected) return;
|
|
|
+ const menu = this.renderRoot.querySelector('.member-menu');
|
|
|
+ menu.showPopover();
|
|
|
+ this.#positionMenu();
|
|
|
+ this.renderRoot.querySelector('.search input').focus({ preventScroll: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ #closeMenu(focus = true) {
|
|
|
+ const menu = this.renderRoot.querySelector('.member-menu');
|
|
|
+ if (menu?.matches(':popover-open')) menu.hidePopover();
|
|
|
+ if (focus) this.renderRoot.querySelector('.member-picker')?.focus({ preventScroll: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ async #onToggle(event) {
|
|
|
+ this._open = event.newState === 'open';
|
|
|
+ if (this._open) {
|
|
|
+ await this.updateComplete;
|
|
|
+ this.#positionMenu();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #toggleMenu() {
|
|
|
+ if (this.renderRoot.querySelector('.member-menu').matches(':popover-open')) this.#closeMenu();
|
|
|
+ else this.#openMenu();
|
|
|
+ }
|
|
|
+
|
|
|
+ #choose(name) {
|
|
|
+ this.#closeMenu();
|
|
|
+ this.dispatchEvent(new CustomEvent('me-change', { detail: { name }, bubbles: true, composed: true }));
|
|
|
+ }
|
|
|
+
|
|
|
+ async #scrollActive() {
|
|
|
+ await this.updateComplete;
|
|
|
+ const list = this.renderRoot.querySelector('.options');
|
|
|
+ const option = this.renderRoot.querySelector(`#member-option-${this._active}`);
|
|
|
+ if (!list || !option) return;
|
|
|
+ const bounds = list.getBoundingClientRect(), row = option.getBoundingClientRect();
|
|
|
+ if (row.top < bounds.top) list.scrollTop += row.top - bounds.top;
|
|
|
+ else if (row.bottom > bounds.bottom) list.scrollTop += row.bottom - bounds.bottom;
|
|
|
+ }
|
|
|
+
|
|
|
+ #filter(event) {
|
|
|
+ this._query = event.target.value;
|
|
|
+ this._active = this._query.trim() && this.#options.length > 1 ? 1 : 0;
|
|
|
+ this.#scrollActive();
|
|
|
+ }
|
|
|
+
|
|
|
+ #onKeydown(event) {
|
|
|
+ const options = this.#options;
|
|
|
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.#closeMenu(); }
|
|
|
+ else if (event.key === 'Tab') this.#closeMenu(false);
|
|
|
+ else if (event.key === 'Enter') { event.preventDefault(); this.#choose(options[this._active]?.name || ''); }
|
|
|
+ else if (['ArrowDown', 'ArrowUp'].includes(event.key) || (!this._query && ['Home', 'End'].includes(event.key))) {
|
|
|
+ event.preventDefault();
|
|
|
+ this._active = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1
|
|
|
+ : (this._active + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length;
|
|
|
+ this.#scrollActive();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ render() {
|
|
|
+ const options = this.#options;
|
|
|
+ return html`
|
|
|
+ <button type="button" class="member-picker" aria-label="查看成员" aria-haspopup="dialog"
|
|
|
+ aria-expanded=${String(this._open)} aria-controls="member-menu" @click=${this.#toggleMenu}
|
|
|
+ @keydown=${event => { if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); this.#openMenu(); } }}>
|
|
|
+ <span class="avatar ${this.value ? '' : 'all'}" aria-hidden="true">${this.value ? Array.from(this.value).at(-1) : icon('users', 22)}</span>
|
|
|
+ <span class="me-title"><span class="member-name"><span class="name">${this.value || '全部成员'}</span><span class="chevron">${icon('chev', 14)}</span></span>
|
|
|
+ <span class="sub">${this.summary}</span></span>
|
|
|
+ </button>
|
|
|
+ <div id="member-menu" class="member-menu" popover="auto" role="dialog" aria-labelledby="member-menu-title"
|
|
|
+ @toggle=${this.#onToggle}
|
|
|
+ @focusout=${event => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) this.#closeMenu(false); }}>
|
|
|
+ <div class="menu-heading"><strong id="member-menu-title">查看成员</strong><small>${options.length - 1} 位</small></div>
|
|
|
+ <div class="search">${icon('search', 16)}
|
|
|
+ <input type="text" placeholder="搜索姓名或小组" aria-label="搜索成员" role="combobox" aria-autocomplete="list"
|
|
|
+ aria-expanded=${String(this._open)} aria-controls="member-options" aria-activedescendant=${this._open ? `member-option-${this._active}` : nothing}
|
|
|
+ .value=${this._query} @input=${this.#filter} @keydown=${this.#onKeydown} autocomplete="off">
|
|
|
+ ${this._query ? html`<button type="button" class="search-clear" aria-label="清空搜索" @click=${() => {
|
|
|
+ this._query = ''; this._active = 0; this.renderRoot.querySelector('input').focus(); this.#scrollActive();
|
|
|
+ }}>${icon('x', 14)}</button>` : ''}
|
|
|
+ </div>
|
|
|
+ <div id="member-options" class="options" role="listbox" aria-label="成员列表">
|
|
|
+ ${options.map((option, index) => html`<button type="button" role="option" tabindex="-1" id=${`member-option-${index}`}
|
|
|
+ class="option ${index === this._active ? 'active' : ''} ${index === 0 ? 'all-option' : ''}" data-member=${option.name}
|
|
|
+ aria-selected=${String(option.name === this.value)} @click=${() => this.#choose(option.name)}>
|
|
|
+ <span class="option-icon" aria-hidden="true">${icon(option.name ? 'user' : 'users', 16)}</span>
|
|
|
+ <span class="name" title=${option.label}>${option.label}</span>
|
|
|
+ ${option.groupId ? html`<span class="group">${option.groupId}</span>` : ''}
|
|
|
+ ${option.name === this.value ? html`<span class="check">${icon('check', 16)}</span>` : ''}
|
|
|
+ </button>`)}
|
|
|
+ ${this._query && options.length === 1 ? html`<p class="no-match" role="status">未找到匹配成员</p>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+define('kf-member-picker', KfMemberPicker);
|