Recipes
Patterns recipes
Each component page documents itself. This page is for combos: real flows that wire 3-4 components together. Copy a recipe, swap the data, ship it.
On this page
Recipes
- Confirm-and-delete flow — Dialog + Form + Toast
- Searchable select — Combobox inside Field
- Tabbed settings with URL hash — Tabs + lazy load + history
- CRUD admin row — Table + Menu + Dialog + Toast
- Auth flow — Form + Field + Validation + Button
Recipe 1
Confirm-and-delete flow
The single most common destructive flow: user clicks Delete on a row, an alert dialog confirms, and after success a Toast confirms (with an Undo affordance for graceful recovery).
Uses ren-alert-dialog ren-button ren-toast
<!-- 1. Trigger -->
<button class="ren-btn ren-btn-danger" type="button"
data-dialog-trigger="dlg-confirm-delete">Delete project</button>
<!-- 2. Confirm dialog (alert variant: no backdrop dismissal) -->
<ren-alert-dialog id="dlg-confirm-delete"
aria-labelledby="dlg-confirm-delete-title">
<dialog class="ren-dialog ren-alert-dialog">
<div class="ren-dialog-header">
<h2 class="ren-dialog-title" id="dlg-confirm-delete-title">
Delete this project?
</h2>
</div>
<div class="ren-dialog-body">
All files, history, and team access will be removed permanently.
</div>
<div class="ren-dialog-footer">
<button class="ren-btn ren-btn-secondary" type="button"
data-dialog-close>Keep project</button>
<button class="ren-btn ren-btn-danger" type="button"
data-action="confirm-delete" autofocus>Delete</button>
</div>
</dialog>
</ren-alert-dialog>
<script>
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="confirm-delete"]');
if (!btn) return;
btn.dataset.loading = '';
try {
const backup = await api.deleteProject(projectId);
document.getElementById('dlg-confirm-delete').close();
// 3. Success toast with undo
toast.success('Project deleted', {
duration: 7000,
action: {
label: 'Undo',
onClick: () => api.restoreProject(backup),
},
});
} catch (err) {
toast.danger('Couldn\'t delete', { description: err.message });
} finally {
delete btn.dataset.loading;
}
});
</script>
Edge cases & gotchas
- Always autofocus the safe button. Here it's Delete because the dialog requires explicit choice — but for ambiguous destructive actions, autofocus Cancel.
- Show the toast after the dialog closes. If both are visible at once the focus ring jumps confusingly.
- Undo on a 7s timeout is a hard contract. If your backend can't actually undelete after a minute, don't promise undo — use a non-actionable toast instead.
- Optimistic delete? Remove the row from the UI immediately and call
api.restoreOptimistically(backup)on Undo. Adds complexity but feels instant.
Recipe 2
Searchable select inside a Field
Combobox provides the popover with filterable options, but it doesn't wrap the label / description / error scaffolding. Use Field for that — the same Field you'd use around a plain <input>.
Uses ren-field ren-combobox
<ren-field name="country" required>
<label class="ren-field-label" for="country-cb">Country</label>
<p class="ren-field-description">
Where the team will be billed.
</p>
<ren-combobox id="country-cb" name="country" placeholder="Search countries…">
<div class="ren-combobox-item" data-value="ar">Argentina</div>
<div class="ren-combobox-item" data-value="br">Brazil</div>
<div class="ren-combobox-item" data-value="cl">Chile</div>
<div class="ren-combobox-item" data-value="uy">Uruguay</div>
</ren-combobox>
<p class="ren-field-error" data-for="country"></p>
</ren-field>
Edge cases & gotchas
- Field's
nameand Combobox'snamemust match. Field wires ARIAaria-describedby+aria-invalidby name — mismatch means the error isn't announced. - For async-loaded options, set
asyncon the combobox and callcombo.setItems(arr)from yourren-searchlistener. The Field error scaffolding works the same way. - Don't use Combobox
placeholderas a label. The label inside Field is the accessible name.
Recipe 3
Tabbed settings with URL hash
A settings page where each tab is deep-linkable (#account, #billing, etc.), panels load on demand, and Back / Forward navigates between tabs.
Uses ren-tabs
<ren-tabs data-tabs-hash="true">
<div class="ren-tabs-list" role="tablist">
<button class="ren-tabs-trigger" type="button" data-tab-id="account">Account</button>
<button class="ren-tabs-trigger" type="button" data-tab-id="billing">Billing</button>
<button class="ren-tabs-trigger" type="button" data-tab-id="team">Team</button>
<button class="ren-tabs-trigger" type="button" data-tab-id="api">API keys</button>
</div>
<section class="ren-tabs-panel" data-tab-id="account">
<!-- always-rendered: profile is cheap -->
<h2>Account</h2>
…
</section>
<section class="ren-tabs-panel" data-tab-id="billing" data-lazy>
<!-- lazy: only fetch when visited -->
</section>
<section class="ren-tabs-panel" data-tab-id="team" data-lazy></section>
<section class="ren-tabs-panel" data-tab-id="api" data-lazy></section>
</ren-tabs>
<script>
const tabs = document.querySelector('ren-tabs');
tabs.addEventListener('ren-tab-change', async (e) => {
const panel = tabs.querySelector(`.ren-tabs-panel[data-tab-id="${e.detail.tabId}"]`);
if (panel.dataset.lazy === '' && !panel.dataset.loaded) {
panel.dataset.loaded = '';
panel.innerHTML = await fetch(`/settings/${e.detail.tabId}`).then(r => r.text());
}
});
</script>
Edge cases & gotchas
data-tabs-hash="true"wireslocation.hashautomatically. The hash matchesdata-tab-id.- If your tab IDs collide with anchors on the page (e.g. a section also has
id="account"), prefix them:data-tab-id="t-account". - Lazy panels should keep their
data-loadedflag after the first fetch so revisits don't refetch. - Persist UI state too, not just the tab — if Billing has a form, store its draft in
sessionStorageso the user doesn't lose it on tab switch.
Recipe 4
CRUD admin row
The bread-and-butter admin pattern: rows of data, each with a dropdown menu (Edit, Duplicate, Delete), confirm dialog for destructive actions, toast for the result.
Uses ren-table ren-menu ren-alert-dialog ren-toast
<ren-table data-page-size="20">
<div class="ren-table-wrapper">
<table class="ren-table">
<thead class="ren-table-header">
<tr>
<th class="ren-th ren-th-sortable" data-column="name">Name</th>
<th class="ren-th">Email</th>
<th class="ren-th ren-th-sortable" data-column="role">Role</th>
<th class="ren-th"><span class="ren-sr-only">Actions</span></th>
</tr>
</thead>
<tbody class="ren-table-body">
<tr class="ren-tr" data-row-id="usr_42">
<td class="ren-td">Ana</td>
<td class="ren-td">ana@example.com</td>
<td class="ren-td">Admin</td>
<td class="ren-td">
<ren-menu>
<button class="ren-btn ren-btn-ghost ren-btn-icon" type="button"
aria-label="Actions for Ana">⋯</button>
<div class="ren-menu-content">
<button class="ren-menu-item" type="button"
data-action="edit" data-id="usr_42">Edit</button>
<button class="ren-menu-item" type="button"
data-action="duplicate" data-id="usr_42">Duplicate</button>
<hr class="ren-menu-separator">
<button class="ren-menu-item ren-menu-item-danger" type="button"
data-action="delete" data-id="usr_42">Delete…</button>
</div>
</ren-menu>
</td>
</tr>
<!-- more rows… -->
</tbody>
</table>
</div>
</ren-table>
<script>
let pendingDeleteId = null;
document.addEventListener('ren-menu-select', (e) => {
const action = e.detail.item?.dataset?.action;
const id = e.detail.item?.dataset?.id;
if (!action) return;
if (action === 'edit') openEditDialog(id);
if (action === 'duplicate') api.duplicate(id).then(refreshTable);
if (action === 'delete') {
pendingDeleteId = id;
document.getElementById('dlg-confirm-delete-user').showModal();
}
});
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="confirm-delete-user"]');
if (!btn || !pendingDeleteId) return;
await api.deleteUser(pendingDeleteId);
document.getElementById('dlg-confirm-delete-user').close();
toast.success('User deleted');
refreshTable();
pendingDeleteId = null;
});
</script>
Edge cases & gotchas
- Each Menu button needs
aria-labelthat includes the row identity ("Actions for Ana"), not generic"More". - Use
data-row-idon<tr>matching your entity id (usr_42), not the array index. Sort/filter shouldn't change the identity. - Disable bulk action when no rows selected — listen for
ren-row-selectand toggle the action bar'shidden. - Confirm dialog needs autofocus on the safe button for destructive flows. See Recipe 1.
Recipe 5
Auth flow (sign in)
Sign-in form with email + password, native HTML5 validation, server-side error mirroring, submit-button loading state, and toast confirmation.
Uses ren-form ren-field ren-button ren-toast ren-card
<div class="ren-card ren-stack" style="max-width: 28rem; margin-inline: auto; padding: var(--space-6);">
<h1>Sign in</h1>
<ren-form id="sign-in" novalidate>
<ren-field name="email" required>
<label class="ren-field-label" for="email">Email</label>
<input id="email" type="email" name="email"
class="ren-input" autocomplete="email" required>
<p class="ren-field-error" data-for="email"></p>
</ren-field>
<ren-field name="password" required>
<label class="ren-field-label" for="password">Password</label>
<input id="password" type="password" name="password"
class="ren-input" autocomplete="current-password"
required minlength="8">
<p class="ren-field-error" data-for="password"></p>
</ren-field>
<button class="ren-btn ren-btn-primary ren-btn-full" type="submit">Sign in</button>
</ren-form>
</div>
<script>
const form = document.getElementById('sign-in');
form.addEventListener('ren-submit', async (e) => {
const btn = form.querySelector('button[type="submit"]');
btn.dataset.loading = '';
btn.setAttribute('aria-busy', 'true');
try {
const res = await fetch('/api/sign-in', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(e.detail.formData)),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const { errors } = await res.json();
// Mirror server errors to each field
for (const [field, message] of Object.entries(errors)) {
form.querySelector(`ren-field[name="${field}"]`)
?.setError(message);
}
return;
}
toast.success('Welcome back');
location.assign('/app');
} catch (err) {
toast.danger('Sign-in failed', { description: 'Try again in a moment.' });
} finally {
delete btn.dataset.loading;
btn.removeAttribute('aria-busy');
}
});
</script>
Edge cases & gotchas
novalidateon the form lets Field own the timing — validate on blur, revalidate on input after a first failure.autocompleteon every field matters. Browsers and password managers prefill correctly when it's right.- Don't disable submit until valid. Counter-intuitive but: a disabled button gives no feedback. Let the user submit, then show errors on submit.
- Loading state uses
data-loading+aria-busy="true"— RenDS Button styles both. - Server errors mirror per-field with
field.setError(message). Field announces them viarole="alert". - Successful redirect should happen after the toast — otherwise the toast vanishes with the page.
Contribute
Missing a recipe?
Recipes are biased toward common flows. If you're solving a pattern that touches 3+ components and the answer feels non-obvious, open an issue with a sketch — we add the recipe to keep the next person 30 minutes ahead.