RRenDSv0.13.0

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

  1. Confirm-and-delete flow — Dialog + Form + Toast
  2. Searchable select — Combobox inside Field
  3. Tabbed settings with URL hash — Tabs + lazy load + history
  4. CRUD admin row — Table + Menu + Dialog + Toast
  5. 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 name and Combobox's name must match. Field wires ARIA aria-describedby + aria-invalid by name — mismatch means the error isn't announced.
  • For async-loaded options, set async on the combobox and call combo.setItems(arr) from your ren-search listener. The Field error scaffolding works the same way.
  • Don't use Combobox placeholder as 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" wires location.hash automatically. The hash matches data-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-loaded flag 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 sessionStorage so 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-label that includes the row identity ("Actions for Ana"), not generic "More".
  • Use data-row-id on <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-select and toggle the action bar's hidden.
  • 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

  • novalidate on the form lets Field own the timing — validate on blur, revalidate on input after a first failure.
  • autocomplete on 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 via role="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.