@memberjunction/ng-ui-components
Reusable, standalone Angular UI components for MemberJunction applications — the house replacement for Kendo UI. Every component is token-driven (--mj-* design tokens from _tokens.scss), dark-mode safe by construction, and keyboard/ARIA accessible.
The rule for consuming apps: check here before building. If a control, layout region, overlay, or pattern exists in this package, use it. Hand-rolling an app-prefixed equivalent (a bespoke button, chip, empty state, dialog…) creates divergence this package exists to eliminate. If what you need doesn’t exist, propose it — generic components belong here; app-domain components belong in the app’s shared package.
Installation & usage
Section titled “Installation & usage”The package ships standalone components — no NgModule required (the one exception is MJAccordionModule, a convenience bundle). Import the symbols you use directly:
import { MJButtonDirective, MJEmptyStateComponent, MJDialogComponent } from '@memberjunction/ng-ui-components';
@Component({ standalone: true, imports: [MJButtonDirective, MJEmptyStateComponent, MJDialogComponent], ...})Dependencies: @angular/cdk (overlays) and angular-split (splitter) come along as package dependencies; @angular/common, @angular/core, and rxjs are peers.
Styling model — read this once
Section titled “Styling model — read this once”Components attach styles one of three ways, and one of them needs host cooperation:
- Inline
stylesin the component (most components) — nothing to do. styleUrlscompiled into the component (page-header,slide-panel) — nothing to do.- Global stylesheets shipped as assets — the form controls and overlay chrome (
button,dialog,dropdown,combobox,input,datepicker,splitter,accordion,window,chip,switch/progress,tabs) ship their.scsstodistand expect the host application to import them globally.
If your app runs inside MJ Explorer (explorer-app), category 3 is already handled — the shell imports all of them. A standalone host must add these to its global stylesheet:
@import '@memberjunction/ng-ui-components/dist/lib/button/button';@import '@memberjunction/ng-ui-components/dist/lib/dialog/dialog';@import '@memberjunction/ng-ui-components/dist/lib/dropdown/dropdown';@import '@memberjunction/ng-ui-components/dist/lib/combobox/combobox';@import '@memberjunction/ng-ui-components/dist/lib/input/input';@import '@memberjunction/ng-ui-components/dist/lib/input/input-switch-progress';@import '@memberjunction/ng-ui-components/dist/lib/input/chip';@import '@memberjunction/ng-ui-components/dist/lib/datepicker/datepicker';@import '@memberjunction/ng-ui-components/dist/lib/splitter/splitter';@import '@memberjunction/ng-ui-components/dist/lib/accordion/accordion';@import '@memberjunction/ng-ui-components/dist/lib/window/window';@import '@memberjunction/ng-ui-components/dist/lib/tabs/tabs';tabs is the shared .mj-tabs* chrome consumed by BOTH mj-workspace-tab-strip (this package) and mj-tabstrip (@memberjunction/ng-tabstrip) — without it either strip renders as unstyled divs, so a standalone host using tab strips must include it.
All colors resolve through --mj-* semantic tokens, so light/dark theming requires zero component-level work.
Forms integration
Section titled “Forms integration”mj-combobox, mj-dropdown, mj-datepicker, mj-numeric-input, and mj-switch all implement ControlValueAccessor — they work with [(ngModel)] and reactive forms, including setDisabledState.
Component catalog
Section titled “Component catalog”Controls
Section titled “Controls”button[mjButton] / a[mjButton] — MJButtonDirective
Section titled “button[mjButton] / a[mjButton] — MJButtonDirective”Attribute directive that styles a native button or anchor. Variants: primary | secondary (default) | outline | flat | danger | icon | success | warning. Sizes: sm | md (default) | lg. Toggle mode via [toggleable] + [(selected)] (sets aria-pressed). Dev-mode warns when an icon button lacks an accessible name — pass ariaLabel.
<button mjButton variant="primary" (click)="save()">Save</button><button mjButton variant="icon" ariaLabel="Remove" (click)="remove()"><i class="fa-solid fa-xmark"></i></button><button mjButton variant="flat" [toggleable]="true" [(selected)]="isActive">Toggle</button>mj-combobox — MJComboboxComponent
Section titled “mj-combobox — MJComboboxComponent”Editable text-input combobox (CDK overlay): type-to-filter, keyboard nav, optional custom values ([AllowCustom]). Inputs: Data, TextField, ValueField, ValuePrimitive, Filterable (default true), Placeholder, Disabled. Outputs: ValueChange, FilterChange. Custom item template via <ng-template #mjComboboxItem>.
<mj-combobox [Data]="categories" TextField="text" ValueField="value" [(ngModel)]="selected" [ValuePrimitive]="true" [AllowCustom]="true" />mj-dropdown — MJDropdownComponent
Section titled “mj-dropdown — MJDropdownComponent”Non-editable select (CDK overlay) with optional in-panel filter ([Filterable], default false) and DefaultItem clear option. Same Data/TextField/ValueField/ValuePrimitive contract as combobox. Custom item template via <ng-template #mjDropdownItem>.
Give every dropdown an accessible name. The trigger is a div[role=combobox], so <label for> neither names nor focuses it — an unnamed dropdown announces as “combobox, collapsed” (WCAG 2.1 4.1.2). Use AriaLabelledBy when a visible label already exists, AriaLabel when none does; AriaDescribedBy carries hint/error text, and InputId puts an id on the trigger for other markup to reference. All are applied to the popup listbox as well as the trigger, and a filterable panel’s filter box takes its name from the same source.
<!-- A visible label already on screen (preferred) --><span id="persona-label">Interview persona</span><mj-dropdown AriaLabelledBy="persona-label" [Data]="items" TextField="name" ValueField="id" [(ngModel)]="selectedId" [ValuePrimitive]="true" />
<!-- No visible label --><mj-dropdown AriaLabel="Interview persona" [Data]="items" TextField="name" ValueField="id" [(ngModel)]="selectedId" [ValuePrimitive]="true" />mj-datepicker — MJDatepickerComponent
Section titled “mj-datepicker — MJDatepickerComponent”Text input + calendar popup with Min/Max range disabling and typed-input parsing. Accepts Date | string | null through forms; emits ValueChange: Date | null. (Display format is currently fixed at MM/dd/yyyy.)
<mj-datepicker [(ngModel)]="dueDate" [Min]="minDate" [Max]="maxDate" Placeholder="Select a date" />mj-numeric-input — MJNumericInputComponent
Section titled “mj-numeric-input — MJNumericInputComponent”Native number input that clamps to Min/Max, rounds to Decimals, and formats on blur. Value flows through forms only (no separate output).
<mj-numeric-input [(ngModel)]="quantity" [Min]="0" [Max]="100" [Decimals]="2" />mj-switch — MJSwitchComponent
Section titled “mj-switch — MJSwitchComponent”Boolean toggle with role="switch", optional OnLabel/OffLabel.
<mj-switch [(ngModel)]="isEnabled" OnLabel="On" OffLabel="Off" />[mjClickable] — MJClickableDirective
Section titled “[mjClickable] — MJClickableDirective”Retrofits a non-semantic element (card, tile, row) into an accessible control: sets role, tabindex, aria-label, and makes Enter/Space fire (click). Pass the accessible name as the attribute value; role may be button (default) or link; testId emits data-testid.
<div class="app-card" [mjClickable]="app.Name" (click)="open(app)">…</div>Calendar utilities
Section titled “Calendar utilities”calendar/calendar-utils exports pure helpers shared with the datepicker: BuildCalendarWeeks, FormatDate, FormatDateTime, GetMonthYearLabel, WEEK_DAYS, MONTH_NAMES.
Layout & page chrome
Section titled “Layout & page chrome”The page-* family composes into the standard page skeleton:
<mj-page-layout> <mj-page-header Title="Agents" Icon="fa-solid fa-robot" Subtitle="Manage your agents"> <span meta><mj-stat-badge [Count]="filtered" [Total]="total" Label="agents" /></span> <div actions><button mjButton variant="primary">New</button></div> <div toolbar><mj-page-search [Value]="q" (ValueChange)="q = $event" /></div> </mj-page-header> <mj-page-body> <!-- content; scrolling and the 24px gutter are handled for you --> </mj-page-body></mj-page-layout>And the left-nav shell variant for multi-section pages:
<mj-page-layout> <mj-page-header Title="Admin" Icon="fa-solid fa-gear" /> <mj-page-body [Flex]="true" [Padding]="false" Direction="row"> <mj-left-nav [Sections]="sections" [ActiveId]="activeId" (ItemClicked)="go($event)" /> <mj-left-nav-content [Loading]="loading" [Error]="err"> <mj-page-header-interior Subtitle="Runtime state"> <div actions><button mjButton size="sm">+ Add</button></div> </mj-page-header-interior> <mj-page-body-interior><!-- sub-page content --></mj-page-body-interior> </mj-left-nav-content> </mj-page-body></mj-page-layout>mj-page-layout — outer shell
Section titled “mj-page-layout — outer shell”Flex-column, full-height, overflow:hidden, page background. No inputs.
mj-page-header — page chrome band
Section titled “mj-page-header — page chrome band”Inputs: Title, Icon, Subtitle. Slots: [meta] (badges under the title), [actions] (right-aligned), [toolbar] (secondary row).
mj-page-body — scrolling body region
Section titled “mj-page-body — scrolling body region”Inputs: Padding (default true; false removes the gutter), Flex (default false), Direction: 'row' | 'column' (row stacks to column at ≤700px).
mj-page-header-interior / mj-page-body-interior
Section titled “mj-page-header-interior / mj-page-body-interior”The same pair for sub-pages inside a left-nav shell — card-like chrome, responsive padding. Header-interior adds Role/AriaLabel inputs and the same three slots.
mj-left-nav / mj-left-nav-content — MJLeftNavComponent
Section titled “mj-left-nav / mj-left-nav-content — MJLeftNavComponent”Canonical left rail: sections, flat + tree items (badges, descriptions, disabled), off-canvas drawer at ≤700px. Inputs: Sections: MJLeftNavSection[], ActiveId, ExpandedIds, Width (240), MobileTitle, IconOnly (per-item tooltips + accessible names for an externally-narrowed rail). Outputs: ItemClicked, ItemToggled. Slots: [header], [footer]. Pair with mj-left-nav-content, which provides Loading/Error states and hides (not destroys) content while busy.
Desktop collapse (opt-in): set [Collapsible]="true" and bind [(Collapsed)] — the rail renders a locked-position double-angle toggle chip and, when collapsed, becomes a CollapsedWidth (60px) icons-only strip: labels visually hidden but kept in the a11y tree, section labels folded to divider lines, badges docked on the icon corner, item tooltips auto-enabled. The component owns presentation only — the consumer owns + persists the state (same split as ExpandedIds). Desktop-only; the ≤700px drawer is unaffected. There is deliberately no hover-to-peek.
<mj-left-nav [Sections]="sections" [ActiveId]="activeId" [Collapsible]="true" [(Collapsed)]="railCollapsed" (CollapsedChange)="persist($event)" />Collapsing a rail whose content is richer than a flat icon list:
| Content | Collapsed behavior |
|---|---|
Tree sections (item.children) | Fold to top-level items only — no chevrons, no indentation, children hidden. A top-level item paints active (aria-current="true") when the active item is one of its descendants, so the rail still shows where you are. ExpandedIds is untouched, so the tree returns exactly as the user left it on expand. |
Items with no icon | Render an uppercase monogram from the label, so an item never collapses to a blank-but-clickable button. |
[header] / [footer] slots | Yours to handle. The rail can’t restyle projected content, so a wide header overflows a 60px strip — bind the same collapsed flag in your consumer and project a compact variant. |
mj-tab-nav — MJTabNavComponent
Section titled “mj-tab-nav — MJTabNavComponent”Data-driven tab strip. Tabs: TabConfig[] (key, label, icon?, badge?, badgeVariant?: 'default'|'error'|'warning'|'success'), ActiveKey, (TabChange) emits the key.
mjTabList — MJTabListDirective
Section titled “mjTabList — MJTabListDirective”The ARIA tabs keyboard contract, shared by every MJ tab strip. Apply to the element holding the tabs; mark each tab role="tab" and keep aria-selected truthful. Owns role="tablist", the roving tabindex (one stop per strip), Arrow/Home/End navigation with focus-follows-selection, Enter/Space activation, and Delete/Backspace close. Emits (TabActivateRequested) / (TabCloseRequested) with the tab’s index in the full list plus the element, so index-addressed and id-addressed hosts both map it onto their own model. Skips display:none tabs; leaves keys alone inside editable content.
mj-workspace-card / mj-workspace-tab-strip — MJWorkspaceCardComponent, MJWorkspaceTabStripComponent
Section titled “mj-workspace-card / mj-workspace-tab-strip — MJWorkspaceCardComponent, MJWorkspaceTabStripComponent”The workspace pattern: a screen where the user builds several drafts in parallel, each in a browser-style tab (open, switch, drag-reorder via CDK, close, dirty-dot, rejected/complete states). MJWorkspaceTabStore<TState> is the pure state machine (open-activates-existing, neighbour activation on close, reorder, lifecycle + dirty tracking — exhaustively unit-tested); mj-workspace-tab-strip is dumb presentation over it (renders tabs, emits intent); mj-workspace-card is the slotted frame — card surface, tab-strip row, [workspaceHeader] identity band, a single scrolling body (default slot), and an opt-in standardized footer (ShowFooter: primary confirm + save-as-draft + discard, only the labels vary; [workspaceFooterNote] caption slot). Tabs carry an opaque State the host owns — no app types in the framework. v1 is session-scoped (no DB persistence).
The card is a query container (container-type: size): size content inside it with cqh/cqw, never vh/vw (viewport units break the moment the card sits in a split or a smaller pane).
<mj-workspace-card [Tabs]="store.Tabs" [ActiveId]="store.ActiveId" NewTabLabel="New entry" [ShowFooter]="true" ConfirmLabel="Create entry" (TabSelected)="store.Activate($event)" (TabClosed)="onClose($event)" (NewTabRequested)="openBlank()" (TabReordered)="store.Reorder($event.previousIndex, $event.currentIndex)" (Confirm)="create()" (SaveDraft)="keep()" (Discard)="discard()"> <div workspaceHeader><!-- per-workshop identity badges --></div> <!-- the workshop's form/body --></mj-workspace-card>mjTip — MJWorkspaceTipDirective
Section titled “mjTip — MJWorkspaceTipDirective”Delayed (~450ms still-pointer), non-interactive, token-themed hover tooltip that by default shows only when the host is truncated (scrollWidth > clientWidth); mjTipAlways forces it for hosts whose truncation can’t be measured (a native <select>). Used by the workspace tab strip for full labels on capped tabs.
mj-page-search — MJPageSearchComponent
Section titled “mj-page-search — MJPageSearchComponent”Compact toolbar search input. Placeholder, Value, Icon; (ValueChange) on input.
mj-slide-panel — MjSlidePanelComponent
Section titled “mj-slide-panel — MjSlidePanelComponent”Chrome-only slide-in panel from the right (resizable) or centered dialog (Mode: 'slide' | 'dialog'). Inputs include Title, Visible, Resizable, MinWidthPx, MaxWidthRatio, WidthPx, and a CanClose guard callback. Outputs: Closed, WidthChanged. Dialog-mode body projects into [dialog-content].
Splitter
Section titled “Splitter”Re-export of angular-split — use <as-split> / <as-split-area> directly:
<as-split direction="horizontal"> <as-split-area [size]="30">Left</as-split-area> <as-split-area [size]="70">Right</as-split-area></as-split>mj-accordion-panel — MJAccordionPanelComponent (+ directives)
Section titled “mj-accordion-panel — MJAccordionPanelComponent (+ directives)”Collapsible panel replacing Kendo panelbar/expansion panel. Inputs: Title, Expanded (+ExpandedChange), Disabled, Variant: 'default'|'primary'|'secondary', Size: 'sm'|'md', Bare, FlushBody, Fill (consume leftover height). Rich content via structural directives — *mjAccordionTitle, *mjAccordionActions (rendered outside the toggle button), and *mjAccordionBody (lazy — instantiated on first expand, then kept alive). Import MJAccordionModule to get the panel plus all three directives in one symbol.
<mj-accordion-panel [Expanded]="open" (ExpandedChange)="open = $event"> <ng-template mjAccordionTitle><i class="fa-solid fa-code"></i> Template Editor</ng-template> <ng-template mjAccordionBody><mj-code-editor /></ng-template></mj-accordion-panel>Overlays
Section titled “Overlays”mj-dialog — MJDialogComponent
Section titled “mj-dialog — MJDialogComponent”Modal with backdrop, Esc/backdrop close (Closeable), body scroll lock. Inputs: Visible, Title, Size: 'sm'|'md'|'lg'|'xl'|'auto' (400/600/800/1000px), Width/Height/MinWidth, Role: 'dialog'|'alertdialog'. Output: Close. Visible is not two-way — set it false in your (Close) handler. Slots: default body, mj-dialog-titlebar, mj-dialog-actions.
<mj-dialog [Visible]="show" Title="Confirm" Size="md" (Close)="show = false"> <p>Are you sure?</p> <mj-dialog-actions> <button mjButton variant="primary" (click)="confirm()">Yes</button> <button mjButton (click)="show = false">No</button> </mj-dialog-actions></mj-dialog>MJDialogService — programmatic dialogs
Section titled “MJDialogService — programmatic dialogs”Open(settings): MJDialogRef with { title, content: string | Type, actions: [{ text, primary?, themeColor? }], width, … }. ref.Result (Observable) emits the clicked action or undefined on dismiss; ref.Content.instance exposes a mounted content component; ref.Close(result?).
mj-confirm-dialog — MJConfirmDialogComponent
Section titled “mj-confirm-dialog — MJConfirmDialogComponent”Purpose-built confirmation on top of mj-dialog. Inputs: [(Visible)] (two-way), Type: 'default'|'danger'|'warning'|'info', Title, Message, DetailMessage, ConfirmText, CancelText, Icon, Processing (spinner + blocks dismissal for async work). Outputs: Confirmed (stays open — you close it when the work finishes), Cancelled (auto-closes). Confirm button is leftmost, per MJ dialog convention.
MJConfirmService — promise-based confirms
Section titled “MJConfirmService — promise-based confirms”await Confirm({ message, title?, detail?, type?, … }) → boolean, and ConfirmDelete(...) which forces danger styling and “Delete”. Mounts above mj-window (z-index 20000).
private confirm = inject(MJConfirmService);if (await this.confirm.ConfirmDelete({ message: 'Delete this view?', detail: 'Cannot be undone.' })) { … }mj-window — MJWindowComponent
Section titled “mj-window — MJWindowComponent”Floating, non-modal panel (no backdrop): Draggable, Resizable (8 handles), State: 'default' | 'maximized' with geometry save/restore, Top/Left/Width/Height/MinWidth/MinHeight. Outputs: Close, StateChange, Resize. Methods: SetPosition, SetSize, GetWindowElement. Slots: default body, mj-window-titlebar, mj-window-actions. Visible is not two-way.
mj-filter-popover — MJFilterPopoverComponent
Section titled “mj-filter-popover — MJFilterPopoverComponent”Filter trigger button + CDK popover on desktop, docked bottom-sheet on mobile. Inputs: Label, Icon, ActiveCount (badge), ShowClearAll. Output: ClearAllRequested. Project your filter UI (dropdowns, chips) as content.
mj-bottom-sheet — MJBottomSheetComponent
Section titled “mj-bottom-sheet — MJBottomSheetComponent”Generic mobile bottom sheet: scrim + panel sliding up from the bottom edge with a grab handle. Pure chrome, no breakpoint logic — the caller decides when a sheet is the right presentation. Fully modal: role="dialog" + aria-modal, focus moves in on open and is trapped (Tab/Shift+Tab cycle within), body scroll locks while open, focus returns to the opener on close. Owns enter/exit transitions (class-driven, dismissal animates), Escape, scrim-click close, prefers-reduced-motion. Inputs: [(Visible)] (two-way), Title (optional header), AriaLabel. Output: Closed (after the exit transition). Sits in the mobile-chrome z band (9998/9999, below mj-window); settled open state is transform: none so position: fixed descendants (dropdowns) aren’t displaced.
<mj-bottom-sheet [(Visible)]="sheetOpen" Title="Open Records"> <div class="rows">…</div></mj-bottom-sheet>Patterns
Section titled “Patterns”mj-empty-state — MJEmptyStateComponent
Section titled “mj-empty-state — MJEmptyStateComponent”Centered placeholder for empty/error/no-results states. Inputs: Variant: 'empty'|'no-results'|'success'|'warning'|'error' (drives default icon + live-region role), Size: 'compact'|'default'|'large', Icon, Title, Message, built-in CTA via ActionText/ActionIcon/ActionVariant with (Action). Extra CTAs project into [actions]; richer body projects as default content.
<mj-empty-state Variant="error" Title="Couldn't load permissions" [Message]="error" ActionText="Try Again" ActionIcon="fa-solid fa-rotate-right" (Action)="loadData()" />mj-stat-badge — MJStatBadgeComponent
Section titled “mj-stat-badge — MJStatBadgeComponent”Read-only stat pill, designed for the page-header [meta] slot. Count, Total (renders “X of Y label”), Label, Icon, Variant: 'default'|'success'|'error'|'warning'|'running'|'info'.
<mj-stat-badge [Count]="filteredCount" [Total]="totalCount" Label="agents" />mj-alert — MJAlertComponent
Section titled “mj-alert — MJAlertComponent”Persistent in-flow banner (not a toast). Variant: 'info'|'success'|'warning'|'error', Size: 'sm'|'md', Title, Message, Dismissible + (Dismissed), action buttons via [actions] slot. Correct ARIA live role per variant automatically.
mj-progress-bar — MJProgressBarComponent
Section titled “mj-progress-bar — MJProgressBarComponent”Value (0–100, clamped) or Type="infinite" for indeterminate. Styles ship in the global input-switch-progress sheet (see Styling model).
mj-view-toggle — MJViewToggleComponent
Section titled “mj-view-toggle — MJViewToggleComponent”Segmented control for view modes. Options: { key, icon?, label?, title? }[], ActiveKey, (KeyChange). Provide title for icon-only options.
mj-refresh-button — MJRefreshButtonComponent
Section titled “mj-refresh-button — MJRefreshButtonComponent”Canonical refresh: Loading spins + disables, label auto-hides on mobile. (Clicked) suppressed while loading/disabled.
mj-filter-chip — MJFilterChipComponent
Section titled “mj-filter-chip — MJFilterChipComponent”Clickable filter pill: Label, Icon, Count, Active (+aria-pressed), (Clicked).
mj-filter-field — MJFilterFieldComponent
Section titled “mj-filter-field — MJFilterFieldComponent”Labeled row (icon + uppercase label) wrapping any projected widget — the escape hatch inside filter UIs.
mj-filter-panel — MJFilterPanelComponent
Section titled “mj-filter-panel — MJFilterPanelComponent”Config-driven filter stack: Fields: FilterFieldConfig[] with type: 'text' | 'dropdown' | 'chips' (chips support multi), [(Values)] as a plain record, Reset handling, plus projected custom mj-filter-fields.
mj-applied-filters — MJAppliedFiltersComponent
Section titled “mj-applied-filters — MJAppliedFiltersComponent”“Filtered by …” removable chip row. Renders nothing when Filters is empty, so it’s safe to place unconditionally. (Remove), (ClearAll).
Contributing
Section titled “Contributing”- Generic components belong here; app-domain components don’t. If a component would make sense in any MJ app, add it to this package. If it encodes one app’s domain (e.g. scoring bands), keep it in that app’s shared package.
- Components must be
standalone: true, use--mj-*tokens for every color (no hardcoded hexes), support both themes, and be keyboard/ARIA accessible. - If a component ships a global
.scss, register it in thecopy-assetsscript inpackage.jsonand add it to the import list in this README and inexplorer-app’s global styles. - Update the catalog above when adding or changing a component’s public API.