Files
cn-rdms-web/src/components/custom/attendee-user-picker.vue
dk 13b74cfe97 feat(新增需求评审功能): 新增需求评审功能。
feat(动态切换对象域下的对象):对象域下的对象可以动态切换。
fix(产品需求、项目需求): 按照会议意见修改诸多细节。
fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
2026-05-22 14:05:25 +08:00

1019 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchGetDeptSimpleList, fetchGetUserSimpleList } from '@/service/api';
import { buildMenuTree } from '@/views/system/shared/menu-tree';
import IconEpOfficeBuilding from '~icons/ep/office-building';
import IconEpUser from '~icons/ep/user';
defineOptions({ name: 'AttendeeUserPicker' });
type Source = 'team' | 'all' | 'dept';
type TreeCheckState = 'none' | 'partial' | 'all';
interface AttendeeItem {
userId: string;
nickname: string;
}
interface TeamUser {
userId: string;
userNickname: string;
roleName?: string | null;
}
interface Props {
teamOptions?: TeamUser[];
teamTabLabel?: string;
showDeptTab?: boolean;
showAllUsersTab?: boolean;
}
const props = defineProps<Props>();
const model = defineModel<AttendeeItem[]>({ default: () => [] });
const hasTeamTab = computed(() => props.teamOptions !== undefined);
const hasDeptTab = computed(() => props.showDeptTab !== false);
const hasAllUsersTab = computed(() => props.showAllUsersTab !== false);
const sourceTabs = computed(() => {
const tabs: Array<{ key: Source; label: string }> = [];
if (hasTeamTab.value) {
tabs.push({ key: 'team', label: props.teamTabLabel || '团队' });
}
if (hasDeptTab.value) {
tabs.push({ key: 'dept', label: '部门' });
}
if (hasAllUsersTab.value) {
tabs.push({ key: 'all', label: '全部用户' });
}
return tabs;
});
const getInitialSource = (): Source => {
if (hasTeamTab.value) return 'team';
if (hasDeptTab.value) return 'dept';
return 'all';
};
const source = ref<Source>(getInitialSource());
const popoverVisible = ref(false);
const currentDeptId = ref<string | null>(null);
const treeSearch = ref('');
const userSearch = ref('');
const hideSelected = ref(false);
const allUsers = ref<Api.SystemManage.UserSimple[]>([]);
const deptTree = ref<Api.SystemManage.DeptSimple[]>([]);
const allUsersLoading = ref(false);
const deptTreeLoading = ref(false);
let allUsersLoaded = false;
let deptTreeLoaded = false;
const selectedIdSet = computed(() => new Set(model.value.map(item => item.userId)));
const selectedCount = computed(() => model.value.length);
const referenceSelectedItems = computed(() => model.value.slice(0, 3));
const referenceOverflowCount = computed(() => Math.max(model.value.length - referenceSelectedItems.value.length, 0));
const teamUsers = computed<Api.SystemManage.UserSimple[]>(() =>
(props.teamOptions ?? []).map(item => ({
id: item.userId,
nickname: item.userNickname,
deptName: item.roleName ?? null
}))
);
const mergedUserMap = computed(() => {
const map = new Map<string, Api.SystemManage.UserSimple>();
for (const user of allUsers.value) {
map.set(user.id, user);
}
for (const user of teamUsers.value) {
if (!map.has(user.id)) {
map.set(user.id, user);
}
}
return map;
});
const currentSourceUsers = computed(() => {
if (!isSourceAvailable(source.value)) {
return [];
}
if (source.value === 'team') {
return teamUsers.value;
}
if (source.value === 'all') {
return allUsers.value;
}
if (!currentDeptId.value) {
return allUsers.value;
}
const node = findDeptNode(deptTree.value, currentDeptId.value);
if (!node) {
return allUsers.value;
}
const deptIds = new Set(collectDeptIds(node));
return allUsers.value.filter(user => user.deptId && deptIds.has(user.deptId));
});
const filteredUsers = computed(() => {
const keyword = userSearch.value.trim().toLowerCase();
let list = currentSourceUsers.value;
if (hideSelected.value) {
list = list.filter(item => !selectedIdSet.value.has(item.id));
}
if (!keyword) {
return list;
}
return list.filter(item => {
return (
item.nickname.toLowerCase().includes(keyword) ||
(item.username ?? '').toLowerCase().includes(keyword) ||
(item.deptName ?? '').toLowerCase().includes(keyword)
);
});
});
const deptTreeProps = { children: 'children', label: 'name' } as const;
async function ensureAllUsers() {
if (allUsersLoaded) {
return;
}
allUsersLoading.value = true;
const { error, data } = await fetchGetUserSimpleList();
allUsersLoading.value = false;
if (!error && data) {
allUsers.value = data;
allUsersLoaded = true;
}
}
async function ensureDeptTree() {
if (deptTreeLoaded) {
return;
}
deptTreeLoading.value = true;
const { error, data } = await fetchGetDeptSimpleList();
deptTreeLoading.value = false;
if (!error && data) {
deptTree.value = buildMenuTree(data);
deptTreeLoaded = true;
}
}
async function switchSource(next: Source) {
if (!isSourceAvailable(next)) {
return;
}
if (source.value === next) {
return;
}
source.value = next;
userSearch.value = '';
treeSearch.value = '';
currentDeptId.value = null;
if (next === 'all') {
await ensureAllUsers();
}
if (next === 'dept') {
await Promise.all([ensureAllUsers(), ensureDeptTree()]);
}
}
function isSourceAvailable(next: Source) {
return sourceTabs.value.some(tab => tab.key === next);
}
async function syncSourceWithAvailableTabs() {
if (isSourceAvailable(source.value)) {
return;
}
const fallback = sourceTabs.value[0]?.key;
if (fallback) {
await switchSource(fallback);
}
}
async function preloadCurrentSource() {
if (!isSourceAvailable(source.value)) {
return;
}
if (source.value === 'all') {
await ensureAllUsers();
}
if (source.value === 'dept') {
await Promise.all([ensureAllUsers(), ensureDeptTree()]);
}
}
function handleVisibleChange(value: boolean) {
popoverVisible.value = value;
if (value) {
preloadCurrentSource();
}
}
function normalizeUser(user: Api.SystemManage.UserSimple): AttendeeItem {
return {
userId: user.id,
nickname: user.nickname
};
}
function toggleUser(user: Api.SystemManage.UserSimple) {
if (selectedIdSet.value.has(user.id)) {
model.value = model.value.filter(item => item.userId !== user.id);
return;
}
model.value = [...model.value, normalizeUser(user)];
}
function removeUser(userId: string) {
model.value = model.value.filter(item => item.userId !== userId);
}
function clearSelected() {
model.value = [];
}
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
const ids = [String(node.id)];
if (node.children?.length) {
for (const child of node.children) {
ids.push(...collectDeptIds(child));
}
}
return ids;
}
function findDeptNode(list: Api.SystemManage.DeptSimple[], id: string): Api.SystemManage.DeptSimple | null {
for (const node of list) {
if (String(node.id) === id) {
return node;
}
if (node.children?.length) {
const matched = findDeptNode(node.children, id);
if (matched) {
return matched;
}
}
}
return null;
}
function getDeptUsers(node: Api.SystemManage.DeptSimple) {
const deptIds = new Set(collectDeptIds(node));
return allUsers.value.filter(user => user.deptId && deptIds.has(user.deptId));
}
function getDeptCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
const users = getDeptUsers(node);
if (!users.length) {
return 'none';
}
const selectedCountInDept = users.filter(user => selectedIdSet.value.has(user.id)).length;
if (selectedCountInDept === 0) {
return 'none';
}
if (selectedCountInDept === users.length) {
return 'all';
}
return 'partial';
}
function getDeptCheckStateClass(node: Api.SystemManage.DeptSimple) {
const state = getDeptCheckState(node);
return {
'is-checked': state === 'all',
'is-partial': state === 'partial'
};
}
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
const users = getDeptUsers(node);
if (!users.length) {
return;
}
const state = getDeptCheckState(node);
const selectedMap = new Map(model.value.map(item => [item.userId, item]));
if (state === 'all') {
users.forEach(user => selectedMap.delete(user.id));
} else {
users.forEach(user => selectedMap.set(user.id, normalizeUser(user)));
}
model.value = Array.from(selectedMap.values());
}
function handleDeptNodeClick(node: Api.SystemManage.DeptSimple) {
currentDeptId.value = String(node.id);
}
function getDeptMetaText(node: Api.SystemManage.DeptSimple) {
const count = getDeptUsers(node).length;
return count > 0 ? `${count}` : '';
}
function matchDeptKeyword(node: Api.SystemManage.DeptSimple, keyword: string): boolean {
if (!keyword) {
return true;
}
if (node.name.toLowerCase().includes(keyword)) {
return true;
}
return Boolean(node.children?.some(child => matchDeptKeyword(child, keyword)));
}
const visibleDeptTree = computed(() => {
const keyword = treeSearch.value.trim().toLowerCase();
if (!keyword) {
return deptTree.value;
}
return deptTree.value.filter(node => matchDeptKeyword(node, keyword));
});
function getSelectedName(item: AttendeeItem) {
return mergedUserMap.value.get(item.userId)?.nickname || item.nickname || item.userId;
}
function getUserDeptText(user: Api.SystemManage.UserSimple) {
return user.deptName || '--';
}
watch(
() => props.teamOptions,
() => {
syncSourceWithAvailableTabs();
const validTeamIds = new Set((props.teamOptions ?? []).map(item => item.userId));
const otherSelected = model.value.filter(item => !validTeamIds.has(item.userId));
const teamSelected = model.value.filter(item => validTeamIds.has(item.userId));
model.value = [...teamSelected, ...otherSelected];
}
);
watch(
() => sourceTabs.value.map(tab => tab.key).join(','),
() => {
syncSourceWithAvailableTabs();
}
);
onMounted(() => {
syncSourceWithAvailableTabs().then(() => preloadCurrentSource());
});
</script>
<template>
<div class="attendee-picker">
<ElPopover
:visible="popoverVisible"
trigger="click"
placement="bottom-start"
:width="720"
popper-class="attendee-picker__popper"
@update:visible="handleVisibleChange"
>
<template #reference>
<div
class="attendee-picker__reference"
:class="{ 'is-focus': popoverVisible }"
tabindex="0"
@keydown.enter.prevent="handleVisibleChange(true)"
@keydown.space.prevent="handleVisibleChange(true)"
>
<div v-if="model.length" class="attendee-picker__reference-chips">
<span v-for="item in referenceSelectedItems" :key="item.userId" class="attendee-picker__reference-chip">
<IconEpUser class="attendee-picker__reference-chip-icon" />
<span class="attendee-picker__reference-chip-name">{{ getSelectedName(item) }}</span>
<button
type="button"
class="attendee-picker__reference-chip-remove"
@click.stop="removeUser(item.userId)"
>
×
</button>
</span>
<span v-if="referenceOverflowCount" class="attendee-picker__reference-count">
+{{ referenceOverflowCount }}
</span>
</div>
<span v-else class="attendee-picker__placeholder">请选择参会人</span>
<span class="attendee-picker__arrow" :class="{ 'is-open': popoverVisible }" />
</div>
</template>
<div class="attendee-picker__dropdown">
<template v-if="sourceTabs.length">
<div class="attendee-picker__tabs">
<button
v-for="tab in sourceTabs"
:key="tab.key"
type="button"
class="attendee-picker__tab"
:class="{ 'is-active': source === tab.key }"
@click="switchSource(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div class="attendee-picker__body" :class="{ 'is-with-tree': source === 'dept' }">
<div v-if="source === 'dept'" class="attendee-picker__panel attendee-picker__panel--tree">
<div class="attendee-picker__panel-head">部门</div>
<div class="attendee-picker__search">
<ElInput v-model="treeSearch" size="small" clearable placeholder="搜索部门..." />
</div>
<div v-loading="deptTreeLoading" class="attendee-picker__panel-body">
<ElTree
:data="visibleDeptTree"
:props="deptTreeProps"
node-key="id"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="attendee-picker__tree"
@node-click="handleDeptNodeClick"
>
<template #default="{ data }">
<div class="attendee-picker__node" :class="{ 'is-active': currentDeptId === String(data.id) }">
<span
class="attendee-picker__check"
:class="getDeptCheckStateClass(data)"
@click.stop="toggleDeptCheck(data)"
/>
<IconEpOfficeBuilding class="attendee-picker__node-icon" />
<span class="attendee-picker__node-label">{{ data.name }}</span>
<span v-if="getDeptMetaText(data)" class="attendee-picker__node-meta">
{{ getDeptMetaText(data) }}
</span>
</div>
</template>
</ElTree>
</div>
</div>
<div class="attendee-picker__panel attendee-picker__panel--users">
<div class="attendee-picker__panel-head attendee-picker__panel-head--users">
<span>
候选用户(
<span>{{ filteredUsers.length }}</span>
)
</span>
<ElCheckbox v-model="hideSelected" size="small">隐藏已选</ElCheckbox>
</div>
<div class="attendee-picker__search">
<ElInput v-model="userSearch" size="small" clearable placeholder="搜索用户名 / 部门..." />
</div>
<div v-loading="allUsersLoading" class="attendee-picker__panel-body">
<div v-if="!filteredUsers.length" class="attendee-picker__empty">暂无匹配用户</div>
<div
v-for="user in filteredUsers"
:key="user.id"
class="attendee-picker__user-row"
@click="toggleUser(user)"
>
<span class="attendee-picker__check" :class="{ 'is-checked': selectedIdSet.has(user.id) }" />
<span class="attendee-picker__avatar">{{ user.nickname.slice(0, 1) }}</span>
<div class="attendee-picker__user-main">
<div class="attendee-picker__user-name">{{ user.nickname }}</div>
<div class="attendee-picker__user-meta">{{ getUserDeptText(user) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="attendee-picker__selected">
<div class="attendee-picker__selected-head">
<span>
已选
<strong>{{ selectedCount }}</strong>
</span>
<button type="button" class="attendee-picker__link" @click="clearSelected">清空</button>
</div>
<div v-if="!model.length" class="attendee-picker__selected-empty">请选择参会人</div>
<div v-else class="attendee-picker__chips">
<span v-for="item in model" :key="item.userId" class="attendee-picker__chip">
<IconEpUser class="attendee-picker__chip-icon" />
<span class="attendee-picker__chip-name">{{ getSelectedName(item) }}</span>
<button type="button" class="attendee-picker__chip-remove" @click="removeUser(item.userId)">×</button>
</span>
</div>
</div>
</template>
<div v-else class="attendee-picker__empty attendee-picker__empty--source">暂无可选人员来源</div>
</div>
</ElPopover>
</div>
</template>
<style scoped>
.attendee-picker {
width: 100%;
}
:global(.attendee-picker__popper.el-popover) {
width: min(720px, calc(100vw - 32px)) !important;
}
.attendee-picker__reference {
display: flex;
align-items: center;
width: 100%;
min-height: 32px;
gap: 8px;
padding: 1px 30px 1px 11px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-blank);
box-shadow: 0 0 0 0 transparent inset;
color: var(--el-text-color-primary);
cursor: pointer;
font-size: 13px;
line-height: 30px;
outline: none;
position: relative;
transition:
border-color var(--el-transition-duration),
box-shadow var(--el-transition-duration);
}
.attendee-picker__reference:hover,
.attendee-picker__reference.is-focus {
border-color: var(--el-color-primary);
}
.attendee-picker__reference.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
.attendee-picker__reference-chips {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
gap: 6px;
}
.attendee-picker__reference-chip {
display: inline-flex;
align-items: center;
max-width: 124px;
height: 24px;
gap: 4px;
padding: 0 4px 0 7px;
border: 1px solid var(--el-border-color);
border-radius: 999px;
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
line-height: 22px;
}
.attendee-picker__reference-chip-icon {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 13px;
}
.attendee-picker__reference-chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__reference-chip-remove {
width: 16px;
height: 16px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 12px;
line-height: 16px;
}
.attendee-picker__reference-chip-remove:hover {
background: var(--el-color-danger);
color: #fff;
}
.attendee-picker__reference-count {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 12px;
}
.attendee-picker__placeholder {
color: var(--el-text-color-placeholder);
}
.attendee-picker__arrow {
position: absolute;
top: 50%;
right: 11px;
width: 7px;
height: 7px;
border-right: 1px solid var(--el-text-color-placeholder);
border-bottom: 1px solid var(--el-text-color-placeholder);
transform: translateY(-65%) rotate(45deg);
transition: transform var(--el-transition-duration);
}
.attendee-picker__arrow.is-open {
transform: translateY(-35%) rotate(225deg);
}
.attendee-picker__dropdown {
display: flex;
flex-direction: column;
gap: 10px;
}
.attendee-picker__tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--el-border-color);
}
.attendee-picker__tab {
margin-bottom: -1px;
padding: 6px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 12.5px;
}
.attendee-picker__tab.is-active {
border-bottom-color: var(--el-color-primary);
color: var(--el-color-primary);
font-weight: 600;
}
.attendee-picker__body {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
min-height: 240px;
height: min(280px, 40vh);
}
.attendee-picker__body.is-with-tree {
grid-template-columns: 240px 1fr;
}
.attendee-picker__panel {
display: flex;
min-width: 0;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: #fff;
}
.attendee-picker__panel-head {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
background: #fafbfc;
color: var(--el-text-color-regular);
font-size: 12px;
}
.attendee-picker__panel-head--users {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.attendee-picker__search {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
}
.attendee-picker__panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.attendee-picker__tree {
padding: 4px;
background: transparent;
}
.attendee-picker__tree :deep(.el-tree-node__content) {
height: 32px;
padding-right: 8px !important;
border-radius: 4px;
}
.attendee-picker__tree :deep(.el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
.attendee-picker__tree :deep(.el-tree-node__expand-icon) {
padding: 4px;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
.attendee-picker__node {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
flex: 1;
gap: 8px;
color: var(--el-text-color-primary);
font-size: 13px;
}
.attendee-picker__node.is-active {
color: var(--el-color-primary);
font-weight: 500;
}
.attendee-picker__check {
position: relative;
width: 14px;
height: 14px;
flex-shrink: 0;
border: 1px solid var(--el-border-color);
border-radius: 2px;
background: var(--el-bg-color);
cursor: pointer;
}
.attendee-picker__check:hover {
border-color: var(--el-color-primary);
}
.attendee-picker__check.is-checked,
.attendee-picker__check.is-partial {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
}
.attendee-picker__check.is-checked::after {
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
border: solid #fff;
border-width: 0 1px 1px 0;
content: '';
transform: rotate(45deg);
}
.attendee-picker__check.is-partial::after {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 2px;
margin: -1px 0 0 -4px;
border-radius: 1px;
background: #fff;
content: '';
}
.attendee-picker__node-icon {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 15px;
}
.attendee-picker__node-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__node-meta {
flex-shrink: 0;
padding-left: 6px;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.attendee-picker__user-row {
display: flex;
align-items: center;
height: 42px;
gap: 10px;
padding: 0 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
cursor: pointer;
}
.attendee-picker__user-row:hover {
background: var(--el-fill-color);
}
.attendee-picker__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
color: #fff;
font-size: 12px;
font-weight: 600;
}
.attendee-picker__user-main {
min-width: 0;
flex: 1;
overflow: hidden;
}
.attendee-picker__user-name,
.attendee-picker__user-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__user-name {
color: var(--el-text-color-primary);
font-size: 13px;
}
.attendee-picker__user-meta {
margin-top: 2px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.attendee-picker__empty {
padding: 40px 0;
color: var(--el-text-color-placeholder);
font-size: 12px;
text-align: center;
}
.attendee-picker__selected {
padding: 8px 12px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: #f8fafc;
}
.attendee-picker__selected-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--el-text-color-regular);
font-size: 11.5px;
}
.attendee-picker__selected-head strong {
color: var(--el-color-primary);
font-size: 12.5px;
}
.attendee-picker__selected-empty {
display: flex;
align-items: center;
min-height: 26px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.attendee-picker__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.attendee-picker__chip {
display: inline-flex;
align-items: center;
max-width: 160px;
gap: 4px;
padding: 2px 4px 2px 8px;
border: 1px solid var(--el-border-color-darker);
border-radius: 999px;
background: #fff;
font-size: 11.5px;
}
.attendee-picker__chip-icon {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 13px;
}
.attendee-picker__chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__chip-remove {
width: 16px;
height: 16px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 11px;
}
.attendee-picker__chip-remove:hover {
background: var(--el-color-danger);
color: #fff;
}
.attendee-picker__link {
padding: 0;
border: none;
background: transparent;
color: var(--el-color-danger);
cursor: pointer;
font-size: 11.5px;
}
.attendee-picker__link:hover {
text-decoration: underline;
}
@media (width <= 1024px) {
.attendee-picker__body,
.attendee-picker__body.is-with-tree {
grid-template-columns: 1fr;
height: auto;
}
.attendee-picker__panel-body {
max-height: 240px;
}
}
</style>