Приложение и дашбордappdashboardsaasteamusersrolesinvite
Team Members
Управление командой: роли, статусы, приглашения и удаление в два шага.
Живое превью
Team
4 of 10 seats used
- LOLucía Ortega (you)lucia@mirador.comActive
- DKDaniel Kimdaniel@mirador.comActive
- RJRosa Jiménezrosa@mirador.comActive
- OHOmar Haddadomar@mirador.comInvited
Использование
Скопируйте и вставьте этот код в ваш React-компонент:
tsx
import { TeamMembers } from 'react-component-kit-ui/app';
<TeamMembers seats={10} members={[{ name: 'Lucía Ortega', email: 'lucia@hotel.com', role: 'Owner', you: true }]} />Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/TeamMembers.tsx + TeamMembers.css:
TeamMembers.tsx
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { initials, hueFrom } from './app-icons';
import './TeamMembers.css';
// TODO(port): <option selected> → set defaultValue on the <select>
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
const o: Record<string, string> = {};
for (const decl of String(css ?? '').split(';')) {
const i = decl.indexOf(':');
if (i < 0) continue;
const k = decl.slice(0, i).trim();
if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
}
return o as CSSProperties;
}
interface Member { name: string; email: string; role: string; status?: 'active' | 'invited' | 'suspended'; you?: boolean }
/**
* TeamMembers — Team management list: members with avatar, email, role selector and status,
* an invite form, and remove with confirmation.
*
* @param {string} title - Card title. Default 'Team'.
* @param {Array<{name: string, email: string, role: string, status?: 'active'|'invited'|'suspended', you?: boolean}>} members - People.
* @param {string[]} roles - Role options. Default ['Owner', 'Manager', 'Front desk', 'Housekeeping', 'Viewer'].
* @param {number} seats - Seats in the plan (shows "4 of 10 seats"). Optional.
* @param {string} variant - Style (card|plain). Default 'card'.
* @param {string} className - Extra class.
*
* Accessibility: real list; role selects and remove buttons are labelled with the member's name;
* the invite email field validates natively; changes are announced politely.
* Emits `app:team-invite` `{ email, role }` and `app:team-remove` `{ email }`.
*/
export interface TeamMembersProps {
title?: string;
members?: Member[];
roles?: string[];
seats?: number;
variant?: 'card' | 'plain';
className?: string;
}
export function TeamMembers({
title = 'Team',
members = [
{ name: 'Lucía Ortega', email: 'lucia@mirador.com', role: 'Owner', status: 'active', you: true },
{ name: 'Daniel Kim', email: 'daniel@mirador.com', role: 'Manager', status: 'active' },
{ name: 'Rosa Jiménez', email: 'rosa@mirador.com', role: 'Housekeeping', status: 'active' },
{ name: 'Omar Haddad', email: 'omar@mirador.com', role: 'Front desk', status: 'invited' },
],
roles = ['Owner', 'Manager', 'Front desk', 'Housekeeping', 'Viewer'],
seats = 10,
variant = 'card',
className = '',
}: TeamMembersProps) {
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
document.querySelectorAll<HTMLElement>('[data-team]:not([data-bound])').forEach((team) => {
team.dataset.bound = '';
const list = team.querySelector<HTMLUListElement>('[data-list]')!;
const msg = team.querySelector<HTMLElement>('[data-msg]')!;
const used = team.querySelector<HTMLElement>('[data-used]');
const form = team.querySelector<HTMLFormElement>('[data-invite]')!;
const recount = () => { if (used) used.textContent = String(list.children.length); };
const bindRemove = (btn: HTMLButtonElement) => {
let armed = false;
btn.addEventListener('click', () => {
const row = btn.closest<HTMLElement>('.team__row')!;
const name = row.querySelector('strong')?.textContent?.replace(' (you)', '') ?? '';
if (!armed) {
armed = true;
btn.classList.add('is-armed');
btn.setAttribute('aria-label', `Confirm removing ${name}`);
msg.textContent = `Click again to remove ${name}.`;
window.setTimeout(() => { armed = false; btn.classList.remove('is-armed'); btn.setAttribute('aria-label', `Remove ${name}`); }, 3000);
return;
}
team.dispatchEvent(new CustomEvent('app:team-remove', { bubbles: true, detail: { email: row.dataset.email } }));
const next = (row.nextElementSibling ?? row.previousElementSibling)?.querySelector<HTMLElement>('select, button');
row.remove();
msg.textContent = `${name} was removed from the team.`;
recount();
next?.focus();
});
};
team.querySelectorAll<HTMLButtonElement>('[data-remove]').forEach(bindRemove);
form.addEventListener('submit', (e) => {
e.preventDefault();
const input = form.elements.namedItem('email') as HTMLInputElement;
const role = (form.elements.namedItem('role') as HTMLSelectElement).value;
if (!input.checkValidity()) { msg.textContent = 'Enter a valid email address.'; input.focus(); return; }
const email = input.value.trim();
const name = email.split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const li = document.createElement('li');
li.className = 'team__row is-new';
li.dataset.email = email;
li.innerHTML = `<span class="team__avatar" style="--h:${hueFrom(name)}" aria-hidden="true">${initials(name)}</span>
<span class="team__who"><strong></strong><small></small></span>
<span class="team__status team__status--invited">Invited</span>
<span class="team__role-static"></span>
<button type="button" class="team__remove" data-remove><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button>`;
li.querySelector('strong')!.textContent = name;
li.querySelector('small')!.textContent = email;
li.querySelector('.team__role-static')!.textContent = role;
li.querySelector('button')!.setAttribute('aria-label', `Remove ${name}`);
list.append(li);
bindRemove(li.querySelector<HTMLButtonElement>('[data-remove]')!);
msg.textContent = `Invitation sent to ${email} as ${role}.`;
team.dispatchEvent(new CustomEvent('app:team-invite', { bubbles: true, detail: { email, role } }));
form.reset();
recount();
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<section className={cx(['team', `team--${variant}`, className])} data-team aria-labelledby="team-title" data-rk-5c76e9="">
<header className="team__head" data-rk-5c76e9="">
<div data-rk-5c76e9="">
<h2 className="team__title" id="team-title" data-rk-5c76e9="">{title}</h2>
{seats && <p className="team__seats" data-rk-5c76e9=""><span data-used data-rk-5c76e9="">{members.length}</span> of {seats} seats used</p>}
</div>
</header>
<form className="team__invite" noValidate data-invite data-rk-5c76e9="">
<label className="sr-only" htmlFor="team-email" data-rk-5c76e9="">Email to invite</label>
<input id="team-email" type="email" name="email" placeholder="name@company.com" required autoComplete="email" data-rk-5c76e9="" />
<label className="sr-only" htmlFor="team-role" data-rk-5c76e9="">Role</label>
<select id="team-role" name="role" data-rk-5c76e9="">{roles.filter((r) => r !== 'Owner').map((r, __i: number) => <option key={__i} data-rk-5c76e9="">{r}</option>)}</select>
<button type="submit" data-rk-5c76e9="">Send invite</button>
</form>
<p className="team__msg" aria-live="polite" data-msg data-rk-5c76e9=""></p>
<ul className="team__list" data-list data-rk-5c76e9="">
{members.map((m, __i: number) => (
<li key={__i} className="team__row" data-email={m.email} data-rk-5c76e9="">
<span className="team__avatar" style={st(`--h: ${hueFrom(m.name)}`)} aria-hidden="true" data-rk-5c76e9="">{initials(m.name)}</span>
<span className="team__who" data-rk-5c76e9="">
<strong data-rk-5c76e9="">{m.name}{m.you && <em data-rk-5c76e9=""> (you)</em>}</strong>
<small data-rk-5c76e9="">{m.email}</small>
</span>
<span className={cx(['team__status', `team__status--${m.status ?? 'active'}`])} data-rk-5c76e9="">{m.status === 'invited' ? 'Invited' : m.status === 'suspended' ? 'Suspended' : 'Active'}</span>
<label className="sr-only" htmlFor={`role-${m.email}`} data-rk-5c76e9="">Role for {m.name}</label>
<select id={`role-${m.email}`} className="team__role" disabled={m.role === 'Owner'} data-rk-5c76e9="">
{roles.map((r, __i: number) => <option key={__i} data-rk-5c76e9="">{r}</option>)}
</select>
<button type="button" className="team__remove" aria-label={`Remove ${m.name}`} disabled={m.you} data-remove data-rk-5c76e9="">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" data-rk-5c76e9=""><path d="M18 6 6 18M6 6l12 12" data-rk-5c76e9="" /></svg>
</button>
</li>
))}
</ul>
</section>
</>
);
}
export default TeamMembers;TeamMembers.css
/* TeamMembers — ported from astro-component-kit (app/TeamMembers.astro) */
.team:where([data-rk-5c76e9]) { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-3, 0.75rem); min-width: 0; }
.team--card:where([data-rk-5c76e9]) { padding: var(--sp-5, 1.25rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
.team__title:where([data-rk-5c76e9]) { margin: 0; font-size: 1rem; font-weight: 700; color: var(--c-text-1, #fff); }
.team__seats:where([data-rk-5c76e9]) { margin: 2px 0 0; font-size: 0.8rem; color: var(--c-text-3, #737373); }
.team__invite:where([data-rk-5c76e9]) { display: flex; gap: var(--sp-2, 0.5rem); }
.team__invite:where([data-rk-5c76e9]) input:where([data-rk-5c76e9]), .team__invite:where([data-rk-5c76e9]) select:where([data-rk-5c76e9]), .team__role:where([data-rk-5c76e9]) {
min-height: 40px;
padding: 0 0.75rem;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(255, 255, 255, 0.12));
background: var(--c-bg, #050505);
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.85rem;
}
.team__invite:where([data-rk-5c76e9]) input:where([data-rk-5c76e9]) { flex: 1; min-width: 0; }
.team__invite:where([data-rk-5c76e9]) button:where([data-rk-5c76e9]) { min-height: 40px; padding: 0 1rem; border: 0; border-radius: 10px; background: var(--app-accent, var(--c-primary-light, #d4a017)); color: var(--c-on-primary, #050505); font: inherit; font-size: 0.85rem; font-weight: 700; cursor: pointer; white-space: nowrap; }
.team__msg:where([data-rk-5c76e9]) { min-height: 1.2em; margin: 0; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }
.team__list:where([data-rk-5c76e9]) { display: grid; margin: 0; padding: 0; list-style: none; }
.team__list:where([data-rk-5c76e9]) .team__row { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto 150px 40px; align-items: center; gap: var(--sp-3, 0.75rem); padding: var(--sp-3, 0.75rem) 0; border-top: 1px solid var(--c-border, rgba(255, 255, 255, 0.06)); }
.team__list:where([data-rk-5c76e9]) .team__row.is-new { animation: team-members-team-in 0.3s ease-out; }
@keyframes team-members-team-in { from { opacity: 0; transform: translateY(-4px); } }
.team__list:where([data-rk-5c76e9]) .team__avatar { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: hsl(var(--h) 50% 45%); color: #fff; font-size: 0.72rem; font-weight: 700; }
.team__list:where([data-rk-5c76e9]) .team__who { display: grid; min-width: 0; }
.team__list:where([data-rk-5c76e9]) .team__who strong { font-size: 0.88rem; color: var(--c-text-1, #fff); }
.team__list:where([data-rk-5c76e9]) .team__who em { font-style: normal; font-weight: 500; color: var(--c-text-3, #737373); }
.team__list:where([data-rk-5c76e9]) .team__who small { font-size: 0.76rem; color: var(--c-text-3, #737373); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.team__list:where([data-rk-5c76e9]) .team__status { padding: 2px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: color-mix(in srgb, var(--c-success, #34d399) 14%, transparent); color: var(--c-success, #34d399); }
.team__list:where([data-rk-5c76e9]) .team__status--invited { background: color-mix(in srgb, var(--c-warning, #fbbf24) 14%, transparent); color: var(--c-warning, #fbbf24); }
.team__list:where([data-rk-5c76e9]) .team__status--suspended { background: color-mix(in srgb, var(--c-danger, #f87171) 14%, transparent); color: var(--c-danger, #f87171); }
.team__list:where([data-rk-5c76e9]) .team__role-static { font-size: 0.85rem; color: var(--c-text-2, #a3a3a3); padding-left: 0.75rem; }
.team__role:disabled:where([data-rk-5c76e9]) { opacity: 0.6; }
.team__list:where([data-rk-5c76e9]) .team__remove { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 8px; background: transparent; color: var(--c-text-3, #737373); cursor: pointer; }
.team__list:where([data-rk-5c76e9]) .team__remove:hover { background: color-mix(in srgb, var(--c-danger, #f87171) 12%, transparent); color: var(--c-danger, #f87171); }
.team__list:where([data-rk-5c76e9]) .team__remove.is-armed { background: var(--c-danger, #f87171); color: #fff; }
.team__list:where([data-rk-5c76e9]) .team__remove:disabled { visibility: hidden; }
.team:where([data-rk-5c76e9]) :is(input, select, button):focus-visible:where([data-rk-5c76e9]), .team__list:where([data-rk-5c76e9]) button:focus-visible { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.sr-only:where([data-rk-5c76e9]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@container (max-width: 620px) {
.team__invite:where([data-rk-5c76e9]) { flex-wrap: wrap; }
.team__list:where([data-rk-5c76e9]) .team__row { grid-template-columns: 36px minmax(0, 1fr) 40px; }
.team__list:where([data-rk-5c76e9]) .team__status { display: none; }
.team__role:where([data-rk-5c76e9]), .team__list:where([data-rk-5c76e9]) .team__role-static { grid-column: 2; }
}Краткая информация
- Категория
- Приложение и дашборд
- Имя файла
TeamMembers.tsx- Зависимости
- Нет — чистый React + CSS
- Теги
- appdashboardsaasteamusersrolesinvite