1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
App y Dashboardappdashboardsaasteamusersrolesinvite

Team Members

Gestión de equipo con roles, estado, invitaciones y eliminación en dos pasos.

Vista Previa en Vivo

Team

4 of 10 seats used

  • Lucía Ortega (you)lucia@mirador.comActive
  • Daniel Kimdaniel@mirador.comActive
  • Rosa Jiménezrosa@mirador.comActive
  • Omar Haddadomar@mirador.comInvited

Uso

Copia y pega este código directamente en tu componente 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 }]} />

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: 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; }
}

Información Rápida

Categoría
App y Dashboard
Nombre de archivo
TeamMembers.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
appdashboardsaasteamusersrolesinvite