@use 'sprucecss/scss/spruce' as *;
.team-invite {
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('lg');
padding: spacer('m');
@include layout-stack('m');
&__header {
@include layout-stack('xxs');
}
&__title {
font-size: font-size('h5');
}
&__text {
color: color('muted');
font-size: config('font-size-sm', $typography);
strong {
color: color('heading');
}
}
&__form {
align-items: end;
display: grid;
gap: spacer('s');
@include breakpoint('sm') {
grid-template-columns: minmax(0, 1fr) 9rem auto;
}
}
&__error {
color: color('invalid', 'form');
grid-column: 1 / -1;
margin: 0;
&[hidden] {
display: none;
}
}
&__pending {
border-block-start: config('border-width-base', $display) solid color('border');
padding-block-start: spacer('m');
@include layout-stack('s');
}
&__subtitle {
font-size: config('font-size-base', $typography);
}
&__list {
list-style: none;
padding: 0;
@include layout-stack('xs');
}
&__item {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: spacer('xs') spacer('s');
}
&__initial {
align-items: center;
background-color: color-alpha('primary', 0.12);
block-size: 2.25rem;
border-radius: radius('pill');
color: color('primary');
display: flex;
flex-shrink: 0;
font-weight: 700;
inline-size: 2.25rem;
justify-content: center;
text-transform: uppercase;
}
&__person {
display: flex;
flex: 1 1 12rem;
flex-direction: column;
line-height: config('line-height-md', $typography);
min-inline-size: 0;
}
&__address {
color: color('heading');
font-weight: 600;
overflow-wrap: anywhere;
}
&__meta {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
&__actions {
display: flex;
gap: spacer('xxs');
}
&__empty {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
}
<section class="team-invite" aria-labelledby="team-invite-title" data-team-invite>
<header class="team-invite__header">
<h2 class="team-invite__title" id="team-invite-title">Invite your team</h2>
<p class="team-invite__text">Invitations expire after 7 days. You have <strong data-team-invite-seats>4</strong> free seats.</p>
</header>
<form class="team-invite__form" action="#" method="post" novalidate data-team-invite-form>
<div class="form-group team-invite__email">
<label class="form-label" for="team-invite-email">Email address</label>
<input class="form-control" id="team-invite-email" name="email" type="email" autocomplete="off" placeholder="name@company.com" required aria-describedby="team-invite-error">
</div>
<div class="form-group team-invite__role">
<label class="form-label" for="team-invite-role">Role</label>
<select class="form-control" id="team-invite-role" name="role">
<option>Member</option>
<option>Admin</option>
<option>Viewer</option>
</select>
</div>
<button class="btn btn--primary" type="submit">Send invite</button>
<p class="form-description team-invite__error" id="team-invite-error" hidden data-team-invite-error></p>
</form>
<div class="team-invite__pending">
<h3 class="team-invite__subtitle">Pending invitations</h3>
<ul class="team-invite__list" role="list" data-team-invite-list>
<li class="team-invite__item" data-team-invite-item>
<span class="team-invite__initial" aria-hidden="true">M</span>
<span class="team-invite__person"><span class="team-invite__address">maria@acme.com</span> <span class="team-invite__meta">Admin · Sent 2 days ago</span></span>
<span class="team-invite__actions">
<button class="btn btn--ghost-primary btn--sm" type="button" data-team-invite-resend>Resend<span class="sr-only"> to maria@acme.com</span></button>
<button class="btn btn--ghost-danger btn--sm" type="button" data-team-invite-cancel>Cancel<span class="sr-only"> the invitation of maria@acme.com</span></button>
</span>
</li>
<li class="team-invite__item" data-team-invite-item>
<span class="team-invite__initial" aria-hidden="true">J</span>
<span class="team-invite__person"><span class="team-invite__address">jonas@acme.com</span> <span class="team-invite__meta">Member · Sent 5 days ago</span></span>
<span class="team-invite__actions">
<button class="btn btn--ghost-primary btn--sm" type="button" data-team-invite-resend>Resend<span class="sr-only"> to jonas@acme.com</span></button>
<button class="btn btn--ghost-danger btn--sm" type="button" data-team-invite-cancel>Cancel<span class="sr-only"> the invitation of jonas@acme.com</span></button>
</span>
</li>
</ul>
<p class="team-invite__empty" hidden data-team-invite-empty>No pending invitations.</p>
</div>
<p class="sr-only" aria-live="polite" data-team-invite-status></p>
</section>
(() => {
document.querySelectorAll('[data-team-invite]').forEach((section) => {
const form = section.querySelector('[data-team-invite-form]');
const email = form.elements.email;
const role = form.elements.role;
const error = section.querySelector('[data-team-invite-error]');
const list = section.querySelector('[data-team-invite-list]');
const empty = section.querySelector('[data-team-invite-empty]');
const seats = section.querySelector('[data-team-invite-seats]');
const status = section.querySelector('[data-team-invite-status]');
const template = list.querySelector('[data-team-invite-item]').cloneNode(true);
const addresses = () => [...list.querySelectorAll('.team-invite__address')].map((item) => item.textContent.toLowerCase());
const update = () => {
empty.hidden = list.children.length > 0;
};
const setError = (message) => {
error.textContent = message;
error.hidden = !message;
email.setAttribute('aria-invalid', String(Boolean(message)));
};
form.addEventListener('submit', (event) => {
event.preventDefault();
const value = email.value.trim();
if (!email.checkValidity() || !value) {
setError('Enter an email address, like name@company.com.');
} else if (addresses().includes(value.toLowerCase())) {
setError(`${value} already has a pending invitation.`);
} else if (Number(seats.textContent) === 0) {
setError('There are no free seats left. Add seats to your plan first.');
} else {
const item = template.cloneNode(true);
item.querySelector('.team-invite__initial').textContent = value.charAt(0);
item.querySelector('.team-invite__address').textContent = value;
item.querySelector('.team-invite__meta').textContent = `${role.value} · Sent just now`;
item.querySelector('[data-team-invite-resend] .sr-only').textContent = ` to ${value}`;
item.querySelector('[data-team-invite-cancel] .sr-only').textContent = ` the invitation of ${value}`;
list.prepend(item);
seats.textContent = Number(seats.textContent) - 1;
status.textContent = `Invitation sent to ${value}.`;
setError('');
form.reset();
update();
}
if (!error.hidden) email.focus();
});
list.addEventListener('click', (event) => {
const item = event.target.closest('[data-team-invite-item]');
const address = item?.querySelector('.team-invite__address').textContent;
if (event.target.closest('[data-team-invite-resend]')) {
item.querySelector('.team-invite__meta').textContent = item.querySelector('.team-invite__meta').textContent.replace(/Sent .*/, 'Sent just now');
status.textContent = `Invitation resent to ${address}.`;
}
if (event.target.closest('[data-team-invite-cancel]')) {
item.remove();
seats.textContent = Number(seats.textContent) + 1;
status.textContent = `The invitation of ${address} is canceled.`;
update();
email.focus();
}
});
update();
});
})();