npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@retalia/pos-components

v0.0.32

Published

Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.

Readme

@retalia/pos-components

Presentational Adaptive POS Angular components. State in, typed intents out — no service calls.

API clients live in @retalia/pos-sdk. Components never import that package. The host calls the SDK, then passes display state into the UI.

Requires Angular 21 (@angular/core and @angular/common ^21.2.0).

Install

npm i @retalia/pos-components
npm i @retalia/pos-sdk

Styles

Add the package stylesheet once in the host app (e.g. angular.json styles):

node_modules/@retalia/pos-components/styles/styles.css

Theme via CSS custom properties (--pos-*) defined in that file. Override tokens in the host to rebrand; values not exposed as tokens are not overridable without a library change. Brand wordmarks use --pos-letter-spacing-brand, tight titles use --pos-letter-spacing-tight, keypad keys use --pos-shadow-key, and menus use --pos-shadow-overlay. The masked PIN keeps --pos-letter-spacing-display.

Shared interaction tokens (every component uses these, so one override changes them everywhere):

| Group | Tokens | Default | | ---------- | ----------------------------------------------------------------------------- | ------------------------------------ | | Focus ring | --pos-focus-ring-width, --pos-focus-ring-offset, --pos-focus-ring-color | 2px, 2px, var(--pos-color-ink) | | Layers | --pos-z-raised, --pos-z-sticky, --pos-z-overlay, --pos-z-dialog | 1, 10, 20, 40 | | Motion | --pos-duration-fast, --pos-duration-normal, --pos-easing-standard | 80ms, 120ms, ease | | Icon sizes | --pos-icon-size-xs, --pos-icon-size-sm, --pos-icon-size-md | 0.75rem, 1rem, 1.25rem |

If you change the focus ring colour, keep at least 3:1 contrast against every surface it sits on (WCAG 2.1 AA, non-text contrast). Transitions are switched off under prefers-reduced-motion regardless of the motion tokens.

Layer order. Library elements stack in this order, lowest first. Put host overlays on the same scale so they sit where you expect:

  1. --pos-z-raised — lifted inside a component (workspace pane controls, drop hints, splitters). Components may add +1…+3 for their own order.
  2. --pos-z-sticky — reserved for host sticky bars and headers; above component content, below menus.
  3. --pos-z-overlay — menus and popovers with a click-away backdrop (header menu, workspace palette). The backdrop uses the token, the menu +1.
  4. --pos-z-dialog — modal dialogs and blocking overlays (till devices, card payment, cash statement, item-entry busy overlay).

A host toast that must appear over dialogs can use calc(var(--pos-z-dialog) + 10). Keep the layers in ascending order if you override them.

Use <pos-tokens> (Storybook: Theme / Tokens, or an internal theme lab page) to see every token in action. Click a sample or token to edit values as live overrides — this does not change the package defaults in styles.css. Export a complete :root CSS file for the consuming POS, and import that file back into the lab later.

Tokens gallery

import { Component } from '@angular/core';
import { PosTokens } from '@retalia/pos-components';

@Component({
  selector: 'app-theme-lab',
  imports: [PosTokens],
  template: `<pos-tokens />`,
})
export class ThemeLab {}

Host the exported file after the package stylesheet so --pos-* overrides win:

node_modules/@retalia/pos-components/styles/styles.css
src/styles/pos-theme.css

| | | | -------- | ------------------------------------------------------------ | | Selector | pos-tokens | | Purpose | Documentation / theme lab — not for production POS screens | | Export | Complete :root { --pos-*: … } CSS for the host POS | | Import | Previously exported theme CSS, applied as lab overrides only |

Login

Keypad login UI — presentational only. It does not call auth services.

The host envelope holds login/session state and passes it in. If the host uses a package AuthService, it calls that on login.submit, then updates [session] / [errorMessage]. The component never injects the service.

import { Component, signal } from '@angular/core';
import { PosLogin, PosLoginIntent, PosLoginSessionState } from '@retalia/pos-components';

@Component({
  selector: 'app-root',
  imports: [PosLogin],
  template: `
    <pos-login
      [session]="session()"
      [errorMessage]="errorMessage()"
      (action)="onLoginAction($event)"
    />
  `,
})
export class App {
  readonly session = signal<PosLoginSessionState>({ status: 'signedOut' });
  readonly errorMessage = signal('');

  onLoginAction(intent: PosLoginIntent): void {
    if (intent.type !== 'login.submit') {
      return;
    }
    // Host (or @retalia/pos-sdk ClerkAuthService) calls the API, then:
    // this.session.set({ status: 'busy' });
    // this.session.set({ status: 'signedIn', displayName: clerk.name });
    // this.errorMessage.set('Invalid clerk or password');
  }
}

| | | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Selector | pos-login | | Input session | Display-only login/session state: signedOut | busy | signedIn, optional displayName | | Input errorMessage | Optional error text | | Input showAside | Shop context beside the keypad. Default true. Set false for the keypad alone | | Input bordered | Frame around the login shell. Default false | | Input showBrandImage, brandImage, brandLabel | Same brand rules as the header. brandLabel defaults to RETALIA. Empty hides the word. Empty brandImage uses the built-in mark | | Input shopName, address1, address2, posNumber | Default empty, which hides that line or tile. shopName is the large line; the address lines are the smaller lines under it. posNumber is the till's POS number | | Input date | Date to show (e.g. the trading date). Default null shows the device's current date, which updates at midnight. dateLabel is the caption | | Input locale | Locale for the date, e.g. en-GB. Default uses the browser locale | | Output action | Typed intents: login.key, login.next, login.submit, login.back, … | | Slot [posLoginAside] | Partner content in the shop column, after the date and POS number. Hidden when empty. Import PosLoginAside | | Slot [posLoginPanel] | Partner content under the keypad. Hidden when empty. Import PosLoginPanel |

Connectors, settings, and other partner tools are not built into the keypad. Project them into the slots. A partner who does not use connectors leaves both slots empty.

<pos-login>
  <pos-till-devices
    posLoginAside
    variant="summary"
    [devices]="paymentDevices()"
    [selectedId]="selectedDeviceId()"
    (action)="onTillDevices($event)"
  />
  <pos-till-devices
    posLoginPanel
    variant="link"
    [devices]="paymentDevices()"
    [selectedId]="selectedDeviceId()"
    (action)="onTillDevices($event)"
  />
</pos-login>

The same slots accept any partner content, including a pos-workspace when that column should be arranged like the selling screen.

Till devices

Connector picker for the till. The host passes the connectors — usually the paymentDevices entries from POS config — and stores the id the clerk confirms. The component does not read config or call the SDK.

summary is the shop-column tile. link is the control under the keypad (Select connector until one is chosen, then Till devices). Either opens the same popup. dialog is the popup on its own, for a host that already has a trigger.

import { Component, signal } from '@angular/core';
import {
  PosTillDevices,
  posTillDeviceKey,
  type PosTillDevice,
  type PosTillDevicesIntent,
} from '@retalia/pos-components';

@Component({
  selector: 'app-till-devices',
  imports: [PosTillDevices],
  template: `
    <pos-till-devices
      [devices]="devices()"
      [selectedId]="selectedId()"
      (action)="onTillDevices($event)"
    />
  `,
})
export class TillDevices {
  readonly devices = signal<readonly PosTillDevice[]>([]);
  readonly selectedId = signal('');

  onTillDevices(intent: PosTillDevicesIntent): void {
    if (intent.type === 'tillDevices.confirm') {
      this.selectedId.set(intent.deviceId);
    }
  }
}

A config entry can be passed through as-is. selectedId is deviceId when that field is set, otherwise String(id) (posTillDeviceKey).

const selected = posTillDeviceKey(paymentDevices[0]);

| | | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Selector | pos-till-devices | | Input devices | Connectors to show. terminalType 0 reads as no payment device. isVirtual reads as a virtual connector. Optional printerLabel, paymentLabel, kind, and online fill the card when the host has them | | Input selectedId | Connector already stored for this till. Empty shows the setup state on summary and link | | Input variant | dialog (default), summary, or link | | Input icon | plug draws the link as a button with the connector icon. Default none | | Input disabled | Locks the trigger and the cards | | Input displayOnly | summary only: shows the set-up as plain information (no button, no popup), e.g. beside the login keypad when the link there opens Till devices. Default false | | Input printer | PosTillPrinterChoice \| null: the receipt printer saved for the selected virtual connector, { mode: 'local', label } or { mode: 'none' }. The summary shows it | | Input demoPrinters | PosTillPrinterOption[] (id, label, detail?) offered in the printer chooser as simulated, e.g. for a demo or Storybook. Leave empty in production | | Input terminalTestable | Shows Test terminal on the payment device; the host handles tillDevices.testTerminal. Default false | | Input terminalStatus | idle, testing, ok or failed: the host's terminal check, shown as a badge | | Text inputs | Every label and message has its own input with an English default (connector, local devices, chooser, test print, payment and ready texts) | | Output action | tillDevices.open, tillDevices.select, tillDevices.confirm (deviceId, and printer for a virtual connector), tillDevices.testTerminal (deviceId), tillDevices.done, tillDevices.dismiss | | Keyboard | Focus stays in the popup (and in the printer chooser or Receipt printer settings while open). Escape closes those, goes back from Local devices, finishes Ready, or closes the popup |

The popup has the POC's three steps, Connector › Local devices › Ready:

  1. Connector: pick a card. A hardware connector's button reads Use this connector and goes to Ready. A virtual connector (isVirtual, or kind: 'virtual') needs no hardware but the till picks its own printer, so its button reads Next: local devices ›.
  2. Local devices (virtual only):
    • Receipt printer: Local printer (USB / serial) opens the printer chooser. It lists the printers this browser has paired on this till, plus any demoPrinters; pick one and press Connect. Find printer… opens the browser's own Web Serial picker for a printer that is really plugged in (Chrome and Edge). Once chosen, the tile shows Paired: … with Test print and Change printer. None picks e-receipts only. printerSettings on the device adds "From POS Admin: …".
    • Receipt printer settings: Test print on a demo printer only says the page was sent (simulated). On a real printer it opens the settings, as in the POC: a connected / not found badge, "Connected · USB 0403:6001 · 9600 baud", Disconnect (the browser forgets the port and a printer has to be chosen again), Test print, the baud pills (baudRates, default 9600–115200) and Find it (one line per speed; the readable one is right). Noise on the paper means the speed is wrong. The speed is saved with the printer on Finish. Receipt header, auto-print and the cash drawer are shop settings for POS Admin, not this till, so they are not here.
    • Payment device: the connector's terminal (paymentLabel, paymentDetail), read-only, with Test terminal when terminalTestable.
    • Back returns to the connectors. Finish stays disabled until a printer is chosen, then emits tillDevices.confirm with printer: { mode: 'local', label, usbVendorId?, usbProductId?, baudRate? } (PosTillLocalPrinter) or { mode: 'none' }. The USB ids find the port again next time.
  3. Ready: "This till is set up" with the printer, payment, day end and (virtual) current POS lines. Change goes back to the connectors; Done emits tillDevices.done and closes.

The host only saves what tillDevices.confirm carries; pairing, the printer list and the test page are handled in the popup through PosSerialPrinter. Like focus handling, that is a browser concern, so the component stays free of the SDK and of storage.

onTillDevices(intent: PosTillDevicesIntent): void {
  if (intent.type === 'tillDevices.confirm') {
    this.selectedId.set(intent.deviceId);
    this.printer.set(intent.printer ?? null); // e.g. TillConnectorStore.select(device, { printer })
  }
}

Serial printer

PosSerialPrinter is a UI-only service for a receipt printer plugged into this till, through Web Serial (Chrome and Edge). supported is false where the browser has no Web Serial. pair() opens the browser's own port picker and resolves with { id, label, detail, usbVendorId, usbProductId }, or null if the clerk closes the picker; the browser remembers the port for the site. pairedPrinters() lists ports the site may already use. find({ usbVendorId?, usbProductId?, label? }) finds a saved printer's port again (USB ids first, then the label). printText(id, lines, { baudRate? }) prints plain text and cuts the paper (ESC/POS), e.g. a test page. printBaudMarkers(id, rates?, { baudRate? }) prints ---- 9600 baud ---- at each speed (POS_SERIAL_BAUD_RATES by default), then cuts at baudRate. forget(id) disconnects: the browser drops its permission for the port. Receipt layouts come from @retalia/pos-sdk.

Basket

Presentational sale basket — renders basket.lines[] and totals exactly as the host envelope provides them. It does not price, tax, or discount. Quantity and void only emit intents; the host (or server) updates the envelope. Clearing the sale is a transaction action bar control, not a basket button. The host still dispatches basket.clear.

import { Component, signal } from '@angular/core';
import {
  PosBasket,
  PosBasketIntent,
  PosBasketLine,
  PosBasketTotals,
} from '@retalia/pos-components';

@Component({
  selector: 'app-sale',
  imports: [PosBasket],
  template: `
    <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasketAction($event)" />
  `,
})
export class Sale {
  readonly lines = signal<readonly PosBasketLine[]>([]);
  readonly totals = signal<PosBasketTotals>({
    gross: 0,
    net: 0,
    vat: 0,
    discount: 0,
    due: 0,
    currency: 'EUR',
  });

  onBasketAction(intent: PosBasketIntent): void {
    // Host envelope binding maps:
    //   basket.changeQuantity → change-quantity
    //   basket.voidLine       → void-line
    // Clear sale (`basket.clear`) is dispatched from the transaction action bar.
  }
}

| | | | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | Selector | pos-basket | | Input lines | Envelope basket.lines[] as given (lineId, sku, name, qty, unitPrice, lineTotal, vat, struckThrough) | | Input totals | Envelope totals as given (gross, net, vat, discount, due, currency) | | Input disabled | Optional lock while the host is busy | | Input bordered | Panel frame. Default false | | Output action | Typed intents: basket.changeQuantity, basket.voidLine. basket.clear remains a host intent for the action bar |

Header

Placeable sale chrome. The host passes the title, shop or other context, and clerk name. The menu beside the clerk initial offers sign out and, when the host turns it on (for example for managers), X Report, Cash Statement and Reprint Last Z Report above it (the old POS order). Each item emits an intent; the host decides what it means (clear the session and navigate, or open its cash statement). The component does not know where it sits in the layout.

<pos-header
  [clerkName]="clerkName()"
  [clerkRole]="clerkRole()"
  [canCashStatement]="isManager()"
  [disabled]="busy()"
  (action)="onHeader($event)"
/>

| | | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Selector | pos-header | | Input showBrandImage | Shows an image before the wordmark. Defaults to true. False hides the image, including the built-in mark | | Input brandImage | Image URL. Empty, while the image is shown, uses the built-in Retalia mark | | Input brandLabel | Text after the image. Empty shows no text | | Input title | Screen title. Omitted from the bar when empty | | Input context | Secondary label, such as the shop name | | Input clerkName | Signed-in clerk. The bar shows an initial and the name, and hides the block when empty | | Input clerkRole | Role label under the name, such as Clerk or Manager. Shown in lowercase | | Input signOutLabel | Menu item label. Defaults to Pos Logout | | Input cashStatementLabel | Cash statement menu item label. Defaults to Cash Statement | | Input canCashStatement | Shows Cash Statement above sign out. Defaults to false | | Input canXReport | Shows X Report above Cash Statement. Defaults to false | | Input canReprintZReport | Shows Reprint Last Z Report below Cash Statement. Defaults to false | | Input canReprintCashStatement | Shows Reprint Last Cash Statement below Reprint Last Z Report. Defaults to false | | Input xReportLabel / reprintZReportLabel | Labels. Default X Report / Reprint Last Z Report | | Input reprintCashStatementLabel | Label. Default Reprint Last Cash Statement | | Input menuLabel | Accessible name for the menu button. Defaults to Menu | | Input canSignOut | Hides sign out when false. The menu button is hidden when neither item is shown | | Input disabled | Disables sign out while the host is busy | | Output action | header.signOut, header.xReport, header.cashStatement, header.reprintZReport, header.reprintCashStatement | | Keyboard | Enter, Space or Arrow Down/Up on the menu button opens the menu and focuses an item. Arrow keys, Home and End move between items. Escape closes it and returns focus to the button. Tab closes it |

Cash statement

End-of-day popup for one till. It shows, per line (cash in each currency, and any payment type the shop counts, such as Card), the opening float, takings, payouts and what the till should hold, takes the amounts left in the drawer and taken to the bank, and closes the day after a confirmation step. Every figure comes from the host; the component calculates nothing. When a count changes it emits cashStatement.count; the host previews the figures through the API and passes the new lines back in. Render it with @if while the cash statement is open.

@if (cashStatementOpen()) {
<pos-cash-statement
  [lines]="lines()"
  [totals]="totals()"
  [currency]="currency()"
  [periodLabel]="periodLabel()"
  [busy]="busy()"
  [loading]="loading()"
  [errorMessage]="error()"
  [closed]="closed()"
  [reportNumber]="reportNumber()"
  (action)="onCashStatement($event)"
/>
}

| | | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Selector | pos-cash-statement | | Input lines | PosCashStatementLine[]: key, label, kind, currency, opening, takings, payouts, expected, inDrawer, toBank, counted, variance. Figures in local currency; inDrawer / toBank as counted in the line's currency; null or left out until counted. kind is cash (default: left in drawer and to bank) or payment (e.g. Card: one counted total, sent as inDrawer with toBank 0) | | Input totals | PosCashStatementTotals: opening, takings, payouts, expected, counted, difference | | Input currency | Local currency code for the figures | | Input periodLabel | Period covered, formatted by the host | | Input busy | Disables inputs and buttons while the host previews or closes | | Input loading | True while the figures are first fetched. With no lines yet, shows loadingLabel instead of emptyLabel | | Input errorMessage | Shown in an alert | | Input closed | Shows the closed summary and a Done button | | Input reportNumber | Z report number shown once closed | | Text inputs | title, description, openingLabel, takingsLabel, payoutsLabel, expectedLabel, inDrawerLabel, toBankLabel, paymentCountLabel, countedLabel, differenceLabel, totalLabel, shortLabel, overLabel, balancedLabel, notCountedLabel, invalidAmountLabel, emptyLabel, loadingLabel, cancelLabel, closeDayLabel, confirmMessage, confirmLabel, backLabel, closedTitle, reportNumberLabel, doneLabel | | Output action | cashStatement.count (key, inDrawer, toBank), cashStatement.close, cashStatement.cancel, cashStatement.done | | Keyboard | Focus moves into the dialog and stays there. Escape cancels, goes back from the confirmation step, or finishes once closed |

Browser printer

PosBrowserPrinter is a UI-only service that prints an HTML page through the browser and the printer's driver, from a hidden iframe so the screen itself is not printed. Pair it with the SDK's print templates, for example renderXzReportHtml from @retalia/pos-sdk.

private readonly printer = inject(PosBrowserPrinter);

await this.printer.print(renderXzReportHtml(report, { tillName }));

The print dialog opens and the user picks the printer. To print straight to the default printer with no dialog, start Chrome or Edge with --kiosk-printing. Paper width, cutting and opening the drawer come from the printer driver's settings. print() rejects if the browser refuses to print.

Transaction action bar

Sale commands for the open basket. Cancel and Cash are drawn by the bar, in the danger and success colors, when the envelope lists clearBasket or tender. The host can still project extra controls into the same row. Cancel emits actionBar.clearBasket. The host maps that to basket.clear. Cash emits actionBar.tender.

<pos-transaction-action-bar
  [availableActions]="availableActions()"
  [disabled]="busy()"
  (action)="onActionBar($event)"
>
  <button type="button" (click)="onPark()">Park</button>
</pos-transaction-action-bar>

| | | | ------------------------ | ---------------------------------------------------------- | | Selector | pos-transaction-action-bar | | Input availableActions | clearBasket shows Cancel. tender shows Cash | | Input disabled | Disables Cancel and Cash while the host is busy | | Output action | actionBar.clearBasket or actionBar.tender | | Content | Optional extra controls, laid out in the same wrapping row |

Quick actions

Sidebar links for the sale screen. The section title and the Find receipt label can be translated. Find receipt is prefixed with a magnifying glass and emits quickActions.findReceipt. The host decides what that means; until it is wired, the click has no further effect.

<pos-quick-actions [disabled]="busy()" (action)="onQuickActions($event)" />

| | | | ------------------------ | ---------------------------------------- | | Selector | pos-quick-actions | | Input title | Section heading. Default Quick actions | | Input findReceiptLabel | Link label. Default Find receipt | | Input disabled | Disables the link while the host is busy | | Output action | quickActions.findReceipt |

Item entry

Presentational scan-or-type field for the sale screen. It captures an item reference (barcode scan or typed SKU) and emits an item-entry intent. It does not look up, validate, or price the item — the host envelope maps the intent to item-entry, and the server handles catalog lookup.

import { Component, signal } from '@angular/core';
import { PosItemEntry, PosItemEntryIntent } from '@retalia/pos-components';

@Component({
  selector: 'app-sale',
  imports: [PosItemEntry],
  template: `
    <pos-item-entry
      [busy]="addingItem()"
      [errorMessage]="errorMessage()"
      (action)="onItemEntryAction($event)"
    />
  `,
})
export class Sale {
  readonly addingItem = signal(false);
  readonly errorMessage = signal('');

  onItemEntryAction(intent: PosItemEntryIntent): void {
    // Set addingItem true before AddItem, false when the response returns.
    // Host envelope binding maps:
    //   itemEntry.submit → item-entry  (payload: reference)
  }
}

| | | | --------------------- | ------------------------------------------------------------------------------------------------------ | | Selector | pos-item-entry | | Input disabled | Optional lock without the busy overlay (e.g. no active sale) | | Input busy | True while AddItem (or lookup) is in flight. Locks the field and shows a centered spinner on screen | | Input busyLabel | Status text for the busy overlay. Default Adding item… | | Input bordered | Panel frame. Default false | | Input errorMessage | Optional host-provided error text (lookup failures belong to the host) | | Input label | Accessible name for the field. Default Item. Not shown on the bar | | Input autofocus | Focus the field on render so a scanner can type immediately (default true) | | Input placeholder | Field hint. Default Scan barcode, search products | | Input scannerStatus | Status in the chip at the right of the field. Default scanner ready | | Input focusHotkey | Key that focuses the field, shown beside the chip. Default F2. Empty hides the hint and the shortcut | | Output action | Typed intent: itemEntry.submit with reference |

Enter in the field submits the reference. Set busy while waiting on the host response to show a centered spinner overlay. F2 moves focus back to the field from anywhere else on the page, matching the POS proof of concept. The label is the accessible name and is not shown on the bar. While the field is focused the bar is a white pill with a --pos-color-focus stroke (default #b581fb) and a soft halo in that colour.

Workspace

Logged-in sale shell. It places the header, item lookup, basket, and action bar, with a left-side slot beside them. The default stack matches the sale screen: the header spans the screen with a 1px bottom divider, and a left-side container with a 1px right divider sits under it beside item lookup, a flexible basket, and the action bar. The host still owns each region's state and intents; the workspace only arranges them.

editing draws a Components menu above the screen, plus a dashed outline, a drag handle, a settings control, and a height bar on each region that is placed. Open the menu and drag a component onto the screen, or press it, and it stacks in the left column. The menu scrolls, so a long list stays usable. Drag those panes after that to move them. While one is moving, the right side is highlighted. Drop on the right side and that region docks there at full height. Drop a pane above that docked column to span both columns. Full width is also a setting. A full-width pane at the top of the main stack spans the whole screen, and the side containers sit under it. A pane fills leftover height only when Flexible height is on, which sets its weight above 0. Weight 0 stays the content height. A divider draws a line in the border colour on the top, right, bottom, or left edge of that pane, and only as long as that edge. Divider width sets those lines from 1 to 8 pixels. The default header has a bottom divider and the left side has a right divider, both 1px. Visible hides that pane on the till and keeps it in the editor so it can be shown again. The X removes that pane: a library component goes back to the Components menu, and an empty slot is deleted. fullWidth: true and span: 'top' are the top placement. span: 'bottom' is the bottom placement. Drag the bar under a region to share leftover height, or the divider between columns to change the split. Arrow keys do the same from the keyboard. Undo restores the previous arrangement. Default layout puts the sale stack back: the header full width across the top, a left-side container under it, then item lookup, basket, and the action bar. New clears every region off the screen and returns the library components to the Components menu. The arrangement used when the host does not pass a layout is still that sale stack.

Custom container adds the next slot, empty-1, then empty-2, and so on. On screen they are Custom container 1, Custom container 2, and so on. That slot is only a space. The consuming POS fills it by projecting a pane with the same id. Until that content is projected, the slot keeps the custom-container name. A region that is not listed in layout.panes stays off the screen, including while editing.

Left side and Right side add that next custom container on an edge of the screen. Each one sits in a rail beside the main area and runs the full height, so it can hold the same projected content as any other slot: buttons, a panel, or another component. Drag a pane onto those edges, or choose Main area, Left side, or Right side in its settings. Choosing the place it already uses returns it to the main area. Arrow Left from the left column and Arrow Right from the right column move a pane onto that side. On a narrow screen the side rails join the single column.

layoutChange reports the arrangement when a component is added, moved, resized, removed, or the layout is reset. This package does not save it. Export layout downloads that arrangement as pos-workspace-layout.json and emits layoutExport. Import layout reads that same file and applies it. The consuming POS keeps that file, or the same JSON from a backend, and passes parseWorkspaceLayout of it as layout. A screen that shows fewer regions sets visible: false on those panes of that document. It does not keep a second copy of the borders, edges, or widths. On a narrow screen the regions stack in one column, with the header kept at the top and the action bar at the bottom.

<pos-workspace [editing]="editing()" (layoutChange)="onLayout($event)">
  <pos-workspace-pane paneId="header">
    <pos-header [clerkName]="clerkName()" (action)="onHeader($event)" />
  </pos-workspace-pane>
  <pos-workspace-pane paneId="itemEntry">
    <pos-item-entry (action)="onItemEntry($event)" />
  </pos-workspace-pane>
  <pos-workspace-pane paneId="basket">
    <pos-basket [lines]="lines()" [totals]="totals()" (action)="onBasket($event)" />
  </pos-workspace-pane>
  <pos-workspace-pane paneId="actionBar">
    <pos-transaction-action-bar
      [availableActions]="availableActions()"
      (action)="onActionBar($event)"
    />
  </pos-workspace-pane>
  <pos-workspace-pane paneId="empty-1">
    <app-custom-panel />
  </pos-workspace-pane>
</pos-workspace>

| | | | ---------------------- | ------------------------------------------------------------------------ | | Selector | pos-workspace | | Input editing | Shows outlines, drag handles, and resize controls. Default false | | Input layout | Optional starting arrangement. Default is the sale stack | | Output layoutChange | The arrangement after an add, move, resize, remove, or reset. Not stored | | Output layoutExport | The arrangement from Export layout, ready to pass back as layout | | parseWorkspaceLayout | Reads an exported layout file back into a layout value | | Pane paneId | header, itemEntry, basket, actionBar, or empty-1, empty-2, … | | Pane fullWidth | On a placement in layout. Spans both columns above the split | | Pane span | top or bottom. Spans both columns on that edge of the split | | Pane edge | left or right. A side rail beside the main area, full height | | Pane weight | 0 sizes to content. Above 0, the pane shares leftover height | | Pane visible | Omitted or true shows the pane. false hides it on the till | | Pane dividerTop | true draws a line on the top edge. Omitted leaves that edge clear | | Pane dividerRight | true draws a line on the right edge. Omitted leaves that edge clear | | Pane dividerBottom | true draws a line on the bottom edge. Omitted leaves that edge clear | | Pane dividerLeft | true draws a line on the left edge. Omitted leaves that edge clear | | Pane dividerWidth | Line thickness in pixels, from 1 to 8. Present when a divider side is on |

Tender

Presentational cash tender — coins, notes, and quick amounts build the amount given. Pay Now emits tender.cash. Cancel emits tender.cancel. The host envelope sends the cash intent and passes back changeDue. The component never calculates change or takes payment. For card tender use Card payment.

import { Component, signal } from '@angular/core';
import { PosTender, PosTenderIntent } from '@retalia/pos-components';

@Component({
  selector: 'app-tender',
  imports: [PosTender],
  template: `
    <pos-tender
      [amountDue]="amountDue()"
      [changeDue]="changeDue()"
      [currency]="currency()"
      [disabled]="busy()"
      [errorMessage]="errorMessage()"
      (action)="onTenderAction($event)"
    />
  `,
})
export class Tender {
  readonly amountDue = signal(12);
  readonly changeDue = signal<number | null>(null);
  readonly currency = signal('EUR');
  readonly busy = signal(false);
  readonly errorMessage = signal('');

  onTenderAction(intent: PosTenderIntent): void {
    if (intent.type !== 'tender.cash') {
      return;
    }
    // Host envelope binding maps tender.cash → tender intent,
    // then sets changeDue from the envelope response.
    // this.busy.set(true);
    // this.changeDue.set(envelope.changeDue);
  }
}

| | | | -------------------- | -------------------------------------------------------------------------------------------------- | | Selector | pos-tender | | Input amountDue | Envelope amount due as given — display only | | Input changeDue | Envelope change due as given (null until the host responds). Shown as received, never calculated | | Input currency | ISO currency code used only for display formatting | | Input disabled | Optional lock while the host is busy | | Input errorMessage | Optional error text | | Output action | tender.cash with the amount given, or tender.cancel |

Card payment

Presentational card payment dialog, shown while the host runs the card tender (StartPayment, then CardPaymentProgress every 2 s until Success or Failure). processing shows the amount, the terminal and the status line with a Close button; failed shows the reason with Try again, Other tender and Close. The component never calls the API or polls: the host changes status and statusText from the API response.

import { Component, signal } from '@angular/core';
import {
  PosCardPayment,
  PosCardPaymentIntent,
  PosCardPaymentStatus,
} from '@retalia/pos-components';

@Component({
  selector: 'app-card-payment',
  imports: [PosCardPayment],
  template: `
    <pos-card-payment
      [amount]="amount()"
      [currency]="currency()"
      [status]="status()"
      [terminalName]="terminalName()"
      [errorMessage]="errorMessage()"
      (action)="onCardPayment($event)"
    />
  `,
})
export class CardPayment {
  readonly amount = signal(59);
  readonly currency = signal('GBP');
  readonly status = signal<PosCardPaymentStatus>('processing');
  readonly terminalName = signal('Adyen · S1F2-000158234517');
  readonly errorMessage = signal('');

  onCardPayment(intent: PosCardPaymentIntent): void {
    // cardPayment.close → stop polling and hide the dialog.
    // cardPayment.retry → start a new payment; cardPayment.otherTender → back to tender choice.
  }
}

| | | | --------------------------- | ----------------------------------------------------------------------------------------------- | | Selector | pos-card-payment | | Input amount, currency | Amount being charged, as given; currency is for display formatting only | | Input status | processing (default) or failed | | Input terminalName | Terminal heading, e.g. Adyen · S1F2-000158234517. The terminal box is hidden when empty | | Input terminalDescription | Line under the terminal name | | Input statusText | Status line while processing (default "Sent to the cloud terminal - waiting for the customer…") | | Input errorMessage | Failure reason when failed (falls back to defaultErrorMessage) | | Input showApprove | Adds "Approve on terminal" for demos and mock terminals only | | Input disabled | Locks the buttons while the host is busy | | Labels | title, failedTitle, closeLabel, approveLabel, retryLabel, otherTenderLabel | | Output action | cardPayment.close, cardPayment.approve, cardPayment.retry, cardPayment.otherTender |

Escape emits cardPayment.close; focus moves into the dialog and Tab stays inside it.

Sale complete

Presentational end-of-sale panel: check mark, "Sale complete", the amount and item count ("£49.95 · 1 items"), and Print receipt, Email receipt and Next customer. Values are shown as given.

<pos-sale-complete
  [amount]="paid()"
  [currency]="currency()"
  [itemCount]="itemCount()"
  (action)="onSaleComplete($event)"
/>

| | | | ------------------------------ | ------------------------------------------------------------------------------------- | | Selector | pos-sale-complete | | Input amount, currency | Amount paid, as given; currency is for display formatting only | | Input itemCount | Shown after the amount; hidden when null | | Input detail | Optional extra line (e.g. change due) | | Input showPrint, showEmail | Hide the receipt buttons (default true) | | Input disabled | Locks the buttons while the host is busy | | Labels | title, itemsLabel, printLabel, emailLabel, nextCustomerLabel | | Output action | saleComplete.printReceipt, saleComplete.emailReceipt, saleComplete.nextCustomer |

Receipt

Presentational completed-sale receipt — renders basket.lines[], totals, payments[], and document exactly as the host envelope provides them. It does not format, tax, or calculate change. It can show print buttons (printActions); the host does the printing, with @retalia/pos-sdk's receipt templates.

import { Component, signal } from '@angular/core';
import {
  PosReceipt,
  PosReceiptIntent,
  PosReceiptLine,
  PosReceiptPayment,
  PosReceiptTotals,
  PosReceiptDocument,
} from '@retalia/pos-components';

@Component({
  selector: 'app-receipt',
  imports: [PosReceipt],
  template: `
    <pos-receipt
      [lines]="lines()"
      [totals]="totals()"
      [payments]="payments()"
      [document]="document()"
      [printActions]="['print', 'reprint', 'gift']"
      [printing]="printing()"
      [printMessage]="printMessage()"
      (action)="onReceiptAction($event)"
    />
  `,
})
export class Receipt {
  readonly lines = signal<readonly PosReceiptLine[]>([]);
  readonly totals = signal<PosReceiptTotals>({
    gross: 0,
    net: 0,
    vat: 0,
    discount: 0,
    due: 0,
    currency: 'EUR',
  });
  readonly payments = signal<readonly PosReceiptPayment[]>([]);
  readonly document = signal<PosReceiptDocument>({
    number: '',
    receiptReady: false,
  });

  readonly printing = signal(false);
  readonly printMessage = signal('');

  onReceiptAction(intent: PosReceiptIntent): void {
    switch (intent.type) {
      case 'receipt.newSale':
        // Host envelope binding maps receipt.newSale → newSale.
        return;
      case 'receipt.print':
      case 'receipt.reprint':
      case 'receipt.printGift':
        // The host prints with pos-sdk (renderSaleReceipt / TillReceiptPrinter): a copy for
        // reprint, a gift receipt for intent.lineIds. It sets printing() and printMessage().
        return;
    }
  }
}

| | | | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | Selector | pos-receipt | | Input lines | Envelope basket.lines[] as given (lineId, sku, name, qty, unitPrice, lineTotal, vat, struckThrough) | | Input totals | Envelope totals as given (gross, net, vat, discount, due, currency) | | Input payments | Envelope payments[] as given (tender, amount, tendered, change, state) | | Input document | Envelope document as given (number, receiptReady) | | Input disabled | Optional lock while the host is busy | | Input bordered | Panel frame. Default false | | Input printActions | Print buttons to show, in order: 'print', 'reprint', 'gift'. Default none | | Input printing | Locks the print buttons and shows printingLabel while the host prints | | Input printMessage | Message from the host about printing, e.g. why it failed (role="status") | | Inputs (labels) | printLabel, reprintLabel, giftLabel, giftHint, giftPrintLabel, giftCancelLabel, printingLabel (English defaults) | | Output action | Typed intent: receipt.newSale, receipt.print, receipt.reprint, or receipt.printGift with the chosen lineIds (every line but voided ones is chosen to start with) |