@dloizides/ui-layout
v1.23.0
Published
Themable, brand-agnostic React Native (RN-web) layout primitives — Section, Heading, StatusBadge, UpgradePrompt, ModalDropdown, Modal, ConfirmDialog — sharing the @dloizides/ui-feedback UI context.
Downloads
4,820
Maintainers
Readme
@dloizides/ui-layout
Themable, brand-agnostic React Native (RN-web) layout primitives for the dloizides.com portfolio.
Read theme + translations from the shared @dloizides/ui-feedback context (useUi).
Components
| Export | Purpose |
|--------|---------|
| Section | Bordered card container (themes border + surface). |
| Heading | Themed section heading text. |
| StatusBadge | Status pill — caller supplies label/color/backgroundColor. |
| UpgradePrompt | Free-tier upgrade nudge; CTA navigates to the billing route via useUi().navigate. |
| Tabs | Controlled tabbed section shell. Responsive: a horizontal role=tablist pill strip on wide web, collapsing to a real mobile MENU (a trigger + vertical section list, via ModalDropdown) below collapseBelow (default 768) or on native. The collapsed trigger presents as a caret field (default) or a ☰ hamburger via collapsedTrigger. Per-tab testIDs survive in both modes. Requires the common.tabsMenuHint key. |
| TabsCollapsedTrigger | Enum (Caret / Hamburger) selecting how the collapsed Tabs menu trigger presents. Defaults to Caret. |
| ModalDropdown | Generic dropdown selector. Responsive by default: an inline anchored menu on wide/desktop web, a modal on narrow/mobile — override per screen with variant. Opt into a menu affordance on the default trigger with showCaret (trailing ▾) or showHamburger (leading ☰, wins over showCaret). |
| DropdownVariant | Enum (Menu / Modal) to force a ModalDropdown rendering variant. |
| ModalShell | Full-screen slide-up sheet with a themed header (title + close). For a page-covering editor. Dismiss via onCancel. |
| Modal | Themed centered dialog overlay: scrim + rounded card, optional header with a top-right ✕ close, scrollable body, optional footer. Dismiss via ✕ / backdrop / Escape (web) / hardware back (native); focus-trapped + scroll-locked. size = sm/md/lg. |
| ConfirmDialog | Confirm/cancel popup built on Modal. Cancel + Confirm buttons (from @dloizides/ui-buttons); destructive paints the Confirm red. Cancel is focus-default (safe), and is disabled while busy (Confirm shows a spinner). Cancel / backdrop / Escape → onCancel. |
| Accordion / AccordionItem | Themed expand/collapse disclosure group (replaces hand-rolled <details>/expanders). Controlled or uncontrolled, single- or multi-open, animated, keyboard + screen-reader accessible. |
| useFocusTrap | Web keyboard focus-trap hook (no-op on native). |
Modal & ConfirmDialog
import { Modal, ConfirmDialog } from '@dloizides/ui-layout';
// A centered edit dialog with a top-right ✕:
<Modal visible={editing} title="Edit details" onClose={() => setEditing(false)} footer={<SaveRow />}>
<AttendeeForm />
</Modal>
// A destructive confirmation popup:
<ConfirmDialog
visible={confirming}
destructive
title="Remove attendee?"
message="This can't be undone."
busy={isDeleting}
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>- Dismissal —
Modal: ✕ button, backdrop press, Escape (web), hardware back (native), all →onClose.ConfirmDialog: Cancel, backdrop, Escape →onCancel; it hides the ✕ so the focus trap lands on the safe Cancel action. - testIDs —
Modal(defaultmodal):${testID}-close,${testID}-backdrop.ConfirmDialog(defaultconfirm-dialog):${testID}-confirm,${testID}-cancel,${testID}-message. - i18n keys the host must provide:
common.close(✕ label),common.closeDialogHint,common.dialog(fallback dialog name), and forConfirmDialogdefaultscommon.confirm/common.confirmHint/common.cancel/common.cancelHint. PassconfirmLabel/cancelLabel(already-localized) to override. - a11y —
role="dialog"+aria-modal; focus trapped on web and restored to the opener on close. Note (RN-web):accessibilityHintdoes not reach the DOM, so the ✕'s hovertooltipcarries the visible helper text.
Accordion
A compound component: an Accordion container that owns the open state + AccordionItem
children that read it via context. Each item is a pressable header (title left, optional
right adornment/badge slot, a rotating chevron) over a collapsible body region.
- Controlled — pass
openIds+onOpenChange. - Uncontrolled — pass
defaultOpenIdson the container and/ordefaultOpenper item. allowMultiple(defaulttrue) keeps any number of items open;false= single-open.- Per-item
disabled. - a11y — header is an accessible
button(accessibilityRole="button",accessibilityState={{ expanded }}, webaria-expanded+aria-controls); the body is arole="region". The header renders as a native<button>, so Enter / Space toggle it. - Themed entirely from the active
useUi()theme (border / surface / text colours). Renders borderless so it drops cleanly inside aSection/Card. - Animated open/close via guarded
LayoutAnimation(a no-op where unavailable — never errors on web or native) plus anAnimatedchevron rotation. - testIDs — container
testID(defaultaccordion); per item the header istestID(defaultaccordion-item-<id>), the body<header>-body, the chevron<header>-chevron.
import { Accordion, AccordionItem, Section, StatusBadge } from '@dloizides/ui-layout';
// 1) A form "More options" disclosure (single collapsible section, starts closed):
<Section>
<Accordion>
<AccordionItem id="more" title="More options">
<FuzzinessSlider />
<DatasetPicker />
</AccordionItem>
</Accordion>
</Section>
// 2) An expandable list row per matched candidate (single-open, badge in the right slot):
<Accordion allowMultiple={false} onOpenChange={(ids) => setOpenRow(ids[0])}>
{candidates.map((c) => (
<AccordionItem
key={c.id}
id={c.id}
title={c.name}
right={<StatusBadge label={c.tier} color={c.fg} backgroundColor={c.bg} />}
>
<CandidateDetail candidate={c} />
</AccordionItem>
))}
</Accordion>
// Controlled variant:
<Accordion openIds={openIds} onOpenChange={setOpenIds}>{/* …items… */}</Accordion>The apps supply the header hint string via the shared translate: key common.accordionToggleHint.
ModalDropdown variants
ModalDropdown picks its presentation automatically:
- Wide / desktop web (viewport ≥ 768px) → an inline anchored menu that opens directly
under the trigger (native
<select>feel): dismissible via click-outside /Esc, keyboard-navigable (↑/↓,Home/End,Enter), scrolls when long, and never pushes layout. It is portalled todocument.bodyso no ancestor stacking context oroverflowcan trap or clip it, and it stays glued to its trigger: it re-anchors on scroll (in any ancestor scroll container), on resize, and on layout changes that fire no scroll event at all, then closes once the trigger is no longer meaningfully visible. Visibility is measured against the viewport intersected with every clipping ancestor — RN-web apps scroll an innerScrollView, never the document, so a viewport-only test would never fire. - Narrow / mobile, or any native platform → the original modal / bottom-sheet.
Pass variant to force either behaviour regardless of viewport — the prop overrides the auto choice:
import { ModalDropdown, DropdownVariant } from '@dloizides/ui-layout';
// Responsive (default): inline menu on desktop, modal on mobile.
<ModalDropdown testID="risk" accessibilityLabel="Risk" accessibilityHint="Pick a risk level"
value={risk} options={riskOptions} onChange={setRisk} />
// Force the inline menu everywhere:
<ModalDropdown /* …props… */ variant={DropdownVariant.Menu} />
// Force the modal everywhere:
<ModalDropdown /* …props… */ variant={DropdownVariant.Modal} />accessibilityHint reaches assistive tech on both platforms: natively via accessibilityHint, and
on web via a visually-hidden node referenced by the trigger's aria-describedby (react-native-web
does not map the RN prop, so the hint would otherwise be silently dropped).
The public API is additive — callers that never pass variant keep working and now
get the inline-on-desktop menu for free.
Install
npm install @dloizides/ui-layout @dloizides/ui-feedbackPeer deps: @dloizides/ui-feedback >= 1.1.0, react >= 18, react-native >= 0.74.
Mount a FeedbackUiProvider / UiProvider at the app root so the components pick up your theme,
translations, and navigation.
License
MIT
i18n contract (required)
The kit is fallback-free: the neutral default t returns the key itself, so a host that forgets
a key does not silently show English — it renders the raw dotted name to users, or announces it to
screen readers. LAYOUT_I18N is the machine-readable manifest of every key this package resolves;
bind your missing-key guard to it exactly as you already do for TABLE_I18N / FILTERS_I18N from
@dloizides/ui-tables, and an upgrade can never add a key your app silently fails to define.
import { LAYOUT_I18N } from '@dloizides/ui-layout';
import { TABLE_I18N } from '@dloizides/ui-tables';
// Every value is a translation key this package will call `t(...)` with.
const REQUIRED_KIT_KEYS = [...Object.values(LAYOUT_I18N), ...Object.values(TABLE_I18N)];The manifest cannot drift: every t(...) call in the package references LAYOUT_I18N, and the test
suite fails on a raw key literal in a component or a stale entry in the map.
