Portfolio & markets
A brokerage home screen. The portfolio value trend in an
md-line-chart driven by an
md-segmented-button-set range switch, a
holdings table where every row carries a 7-day
md-sparkline and a gain/loss
md-chip, a market-session
md-status-dot, and a Trade button per row that
opens a buy/sell ticket in an md-bottom-sheet —
shares entered through a synced md-number-field
and md-slider pair, clamped to what the cash balance
(or the position) actually allows.
Live preview — switch the range, then hit Trade on a row to open the ticket
Portfolio value
NYSE — Market open, closes 4:00 PM ET
$108,136.40
AAPLApple Inc.
MSFTMicrosoft Corp.
NVDANVIDIA Corp.
INTCIntel Corp.
VTIVanguard Total Market ETF
Estimated cost
$2,321.00
Cash available $12,400.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<div class="portfolio">
<header>
<span>Portfolio value</span>
<strong>$108,136.40</strong>
<md-chip variant="assist" appearance="filled" color="success" label="+$1,142.18 today"></md-chip>
<md-status-dot state="online" size="small" inline></md-status-dot>
NYSE — Market open
<md-segmented-button-set aria-label="Chart range">
<md-segmented-button value="1w" label="1W" selected></md-segmented-button>
<md-segmented-button value="1m" label="1M"></md-segmented-button>
<md-segmented-button value="1y" label="1Y"></md-segmented-button>
</md-segmented-button-set>
</header>
<md-line-chart area curve="monotone" legend="none" height="240px"
summary="Portfolio value over the selected range."></md-line-chart>
<md-table-container>
<md-table label="Holdings" column-template="1.1fr 0.8fr 1fr 0.9fr 0.9fr 0.7fr" min-width="640px">
<md-table-head>
<md-table-row rowgroup="head">
<md-table-cell head scope="col">Symbol</md-table-cell>
<md-table-cell head scope="col" numeric>Last</md-table-cell>
<md-table-cell head scope="col">7 days</md-table-cell>
<md-table-cell head scope="col" numeric>Value</md-table-cell>
<md-table-cell head scope="col">Gain/loss</md-table-cell>
<md-table-cell head scope="col"></md-table-cell>
</md-table-row>
</md-table-head>
<md-table-body>
<md-table-row value="AAPL">
<md-table-cell><strong>AAPL</strong> Apple Inc.</md-table-cell>
<md-table-cell numeric>$232.10</md-table-cell>
<md-table-cell><md-sparkline color="success" show-tooltip="false" height="24px"></md-sparkline></md-table-cell>
<md-table-cell numeric>$9,284.00</md-table-cell>
<md-table-cell><md-chip variant="assist" appearance="filled" color="success" label="+18.4%"></md-chip></md-table-cell>
<md-table-cell><md-button variant="text">Trade</md-button></md-table-cell>
</md-table-row>
</md-table-body>
</md-table>
</md-table-container>
</div>
<md-bottom-sheet headline="Trade AAPL" closeable bottom-divider>
<md-segmented-button-set aria-label="Order side">
<md-segmented-button value="buy" label="Buy" selected></md-segmented-button>
<md-segmented-button value="sell" label="Sell"></md-segmented-button>
</md-segmented-button-set>
<md-number-field name="shares" label="Shares" min="1" max="53" step="1" value="10"
supporting-text="Market order — executes at the next available price"></md-number-field>
<md-slider aria-label="Shares" min="1" max="53" step="1" value="10" value-indicator></md-slider>
<md-button slot="actions" variant="text">Cancel</md-button>
<md-button slot="actions" variant="filled">Buy 10 AAPL</md-button>
</md-bottom-sheet>
<script type="module">
const chart = document.querySelector('[data-chart]');
const rangeSet = document.querySelector('[data-range]');
const sheet = document.querySelector('[data-ticket]');
const sideSet = document.querySelector('[data-side]');
const priceLine = document.querySelector('[data-ticket-price]');
const qty = document.querySelector('[data-qty]');
const slider = document.querySelector('[data-qty-slider]');
const estimateLabel = document.querySelector('[data-estimate-label]');
const estimate = document.querySelector('[data-estimate]');
const limitNote = document.querySelector('[data-limit]');
const submit = document.querySelector('[data-ticket-submit]');
const cancel = document.querySelector('[data-ticket-cancel]');
const CASH = 12400;
const HOLDINGS = {
AAPL: { name: 'Apple Inc.', price: 232.10, shares: 40,
spark: [221.4, 224.1, 219.8, 226.5, 229.9, 228.4, 232.1] },
MSFT: { name: 'Microsoft Corp.', price: 456.80, shares: 25,
spark: [449.2, 452.6, 447.1, 455.3, 451.8, 458.9, 456.8] },
NVDA: { name: 'NVIDIA Corp.', price: 128.55, shares: 60,
spark: [117.3, 121.8, 119.4, 124.6, 126.2, 125.1, 128.55] },
INTC: { name: 'Intel Corp.', price: 21.48, shares: 150,
spark: [23.1, 22.6, 22.9, 22.1, 21.8, 21.95, 21.48] },
VTI: { name: 'Vanguard Total Market ETF', price: 291.35, shares: 220,
spark: [286.2, 287.9, 285.4, 288.8, 290.1, 289.6, 291.35] }
};
const money = (n) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// Per-row sparklines: arrays cannot cross the attribute boundary,
// so the data is assigned as a JS property.
document.querySelectorAll('[data-spark]').forEach((sp) => {
sp.data = HOLDINGS[sp.getAttribute('data-spark')].spark;
sp.labels = ['Thu', 'Fri', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
});
// Portfolio value per range. Labels and values are index-aligned.
const RANGES = {
'1w': { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
values: [106910, 107420, 106310, 107880, 108136] },
'1m': { labels: ['Jul 25', 'Aug 1', 'Aug 8', 'Aug 15', 'Aug 22'],
values: [103240, 105150, 104480, 106620, 108136] },
'3m': { labels: ['May 30', 'Jun 13', 'Jun 27', 'Jul 11', 'Jul 25', 'Aug 8', 'Aug 22'],
values: [98400, 99850, 101210, 100370, 103240, 104480, 108136] },
'1y': { labels: ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],
values: [84200, 86900, 90400, 88700, 92300, 94100, 91800, 95600, 98400, 101210, 103240, 108136] }
};
chart.valueFormatter = (v) => v == null ? '' : '$' + Math.round(v / 100) / 10 + 'k';
function setRange(key) {
const r = RANGES[key];
chart.xAxis = { data: r.labels, scale: 'category' };
chart.series = [{ label: 'Portfolio value', data: r.values, color: 'primary' }];
}
setRange('1w');
rangeSet.addEventListener('mdChange', (e) => setRange(e.detail[0]));
// --- Buy/sell ticket ---
let symbol = 'AAPL';
let side = 'buy';
function maxShares() {
return side === 'buy'
? Math.max(1, Math.floor(CASH / HOLDINGS[symbol].price))
: HOLDINGS[symbol].shares;
}
function syncTicket() {
const h = HOLDINGS[symbol];
const max = maxShares();
qty.max = max;
slider.max = max;
const n = Math.min(Math.max(qty.value || 1, 1), max);
qty.value = n;
slider.value = n;
estimateLabel.textContent = side === 'buy' ? 'Estimated cost' : 'Estimated proceeds';
estimate.textContent = money(n * h.price);
submit.textContent = (side === 'buy' ? 'Buy ' : 'Sell ') + n + ' ' + symbol;
limitNote.textContent = side === 'buy'
? 'Cash available ' + money(CASH)
: 'You hold ' + h.shares + ' shares';
}
document.querySelectorAll('[data-trade]').forEach((btn) => {
btn.addEventListener('mdClick', () => {
symbol = btn.getAttribute('data-trade');
side = 'buy';
sideSet.querySelectorAll('md-segmented-button').forEach((seg) => {
seg.selected = seg.value === 'buy';
});
sheet.headline = 'Trade ' + symbol + ' — ' + HOLDINGS[symbol].name;
priceLine.textContent = 'Last ' + money(HOLDINGS[symbol].price);
qty.value = Math.min(10, maxShares());
syncTicket();
sheet.show();
});
});
sideSet.addEventListener('mdChange', (e) => {
side = e.detail[0] || side;
syncTicket();
});
// Number field and slider stay in lockstep: programmatic writes are
// commits and emit nothing, so this cannot loop.
const onQty = () => {
if (qty.value == null) return;
slider.value = Math.min(qty.value, Number(slider.max));
const n = slider.value;
estimate.textContent = money(n * HOLDINGS[symbol].price);
submit.textContent = (side === 'buy' ? 'Buy ' : 'Sell ') + n + ' ' + symbol;
};
qty.addEventListener('mdInput', onQty);
qty.addEventListener('mdChange', onQty);
slider.addEventListener('mdInput', (e) => {
qty.value = e.detail.value;
estimate.textContent = money(e.detail.value * HOLDINGS[symbol].price);
submit.textContent = (side === 'buy' ? 'Buy ' : 'Sell ') + e.detail.value + ' ' + symbol;
});
submit.addEventListener('mdClick', () => sheet.close());
cancel.addEventListener('mdClick', () => sheet.close());
</script>import { useState } from 'react';
import {
MdBottomSheet, MdButton, MdChip, MdLineChart, MdNumberField, MdSegmentedButton,
MdSegmentedButtonSet, MdSlider, MdSparkline, MdStatusDot, MdTable, MdTableBody,
MdTableCell, MdTableContainer, MdTableHead, MdTableRow
} from '@awc-ui/react';
const CASH = 12400;
const DAYS = ['Thu', 'Fri', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const HOLDINGS = [
{ symbol: 'AAPL', name: 'Apple Inc.', price: 232.10, shares: 40, value: '$9,284.00',
gain: '+18.4%', color: 'success', spark: [221.4, 224.1, 219.8, 226.5, 229.9, 228.4, 232.1] },
{ symbol: 'MSFT', name: 'Microsoft Corp.', price: 456.80, shares: 25, value: '$11,420.00',
gain: '+9.6%', color: 'success', spark: [449.2, 452.6, 447.1, 455.3, 451.8, 458.9, 456.8] },
{ symbol: 'INTC', name: 'Intel Corp.', price: 21.48, shares: 150, value: '$3,222.00',
gain: '-12.3%', color: 'error', spark: [23.1, 22.6, 22.9, 22.1, 21.8, 21.95, 21.48] }
];
const RANGES = {
'1w': { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], values: [106910, 107420, 106310, 107880, 108136] },
'1m': { labels: ['Jul 25', 'Aug 1', 'Aug 8', 'Aug 15', 'Aug 22'], values: [103240, 105150, 104480, 106620, 108136] },
'1y': { labels: ['Sep', 'Nov', 'Jan', 'Mar', 'May', 'Jul', 'Aug'], values: [84200, 90400, 92300, 91800, 98400, 103240, 108136] }
};
const money = (n) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const maxFor = (h, side) => side === 'buy' ? Math.max(1, Math.floor(CASH / h.price)) : h.shares;
export default function PortfolioMarkets() {
const [range, setRange] = useState('1w');
const [symbol, setSymbol] = useState('AAPL');
const [side, setSide] = useState('buy');
const [qty, setQty] = useState(10);
const [open, setOpen] = useState(false);
const r = RANGES[range];
const h = HOLDINGS.find((x) => x.symbol === symbol);
const max = maxFor(h, side);
const shares = Math.min(Math.max(qty, 1), max); // a typed overshoot clamps to what cash allows
const openTicket = (sym) => {
const next = HOLDINGS.find((x) => x.symbol === sym);
setSymbol(sym);
setSide('buy');
setQty(Math.min(10, maxFor(next, 'buy')));
setOpen(true);
};
return (
<div style={{ display: 'grid', gap: '20px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', justifyContent: 'space-between', gap: '16px' }}>
<div style={{ display: 'grid', gap: '6px' }}>
<span>Portfolio value</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<strong>$108,136.40</strong>
<MdChip variant="assist" appearance="filled" color="success" density={-1} label="+$1,142.18 today" />
</div>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
{/* the dot stays decorative — the adjacent words carry the session state */}
<MdStatusDot state="online" size="small" /> NYSE — Market open, closes 4:00 PM ET
</span>
</div>
<MdSegmentedButtonSet aria-label="Chart range" density={-1} onMdChange={(e) => setRange(e.detail[0])}>
<MdSegmentedButton value="1w" label="1W" selected={range === '1w'} />
<MdSegmentedButton value="1m" label="1M" selected={range === '1m'} />
<MdSegmentedButton value="1y" label="1Y" selected={range === '1y'} />
</MdSegmentedButtonSet>
</div>
{/* series and xAxis are arrays/objects, so they ride JSX props, not attributes */}
<MdLineChart area curve="monotone" legend="none" grid="horizontal" heightProp="240px"
summary="Portfolio value over the selected range."
xAxis={{ data: r.labels, scale: 'category' }}
series={[{ label: 'Portfolio value', data: r.values, color: 'primary' }]}
valueFormatter={(v) => v == null ? '' : '$' + Math.round(v / 100) / 10 + 'k'} />
<MdTableContainer>
<MdTable label="Holdings" columnTemplate="1.1fr 0.8fr 1fr 0.9fr 0.9fr 0.7fr" minWidth="640px">
<MdTableHead>
<MdTableRow rowgroup="head">
<MdTableCell head scope="col">Symbol</MdTableCell>
<MdTableCell head scope="col" numeric>Last</MdTableCell>
<MdTableCell head scope="col">7 days</MdTableCell>
<MdTableCell head scope="col" numeric>Value</MdTableCell>
<MdTableCell head scope="col">Gain/loss</MdTableCell>
<MdTableCell head scope="col" />
</MdTableRow>
</MdTableHead>
<MdTableBody>
{HOLDINGS.map((row) => (
<MdTableRow key={row.symbol} value={row.symbol}>
<MdTableCell><strong>{row.symbol}</strong> {row.name}</MdTableCell>
<MdTableCell numeric>{money(row.price)}</MdTableCell>
<MdTableCell>
<MdSparkline data={row.spark} labels={DAYS} color={row.color} showTooltip={false} heightProp="24px" />
</MdTableCell>
<MdTableCell numeric>{row.value}</MdTableCell>
<MdTableCell><MdChip variant="assist" appearance="filled" color={row.color} density={-2} label={row.gain} /></MdTableCell>
<MdTableCell><MdButton variant="text" onMdClick={() => openTicket(row.symbol)}>Trade</MdButton></MdTableCell>
</MdTableRow>
))}
</MdTableBody>
</MdTable>
</MdTableContainer>
<MdBottomSheet open={open} closeable bottomDivider headline={'Trade ' + symbol + ' — ' + h.name}
onMdClose={() => setOpen(false)}>
<div style={{ display: 'grid', gap: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px' }}>
<MdSegmentedButtonSet aria-label="Order side" density={-1}
onMdChange={(e) => e.detail.length && setSide(e.detail[0])}>
<MdSegmentedButton value="buy" label="Buy" selected={side === 'buy'} />
<MdSegmentedButton value="sell" label="Sell" selected={side === 'sell'} />
</MdSegmentedButtonSet>
<span>Last {money(h.price)}</span>
</div>
{/* both inputs render the same clamped state — programmatic writes are silent, so no loop */}
<MdNumberField label="Shares" min={1} max={max} step={1} value={shares}
supportingText="Market order — executes at the next available price"
onMdInput={(e) => e.detail.value != null && setQty(e.detail.value)} />
<MdSlider aria-label="Shares" min={1} max={max} step={1} value={shares} valueIndicator
onMdInput={(e) => setQty(e.detail.value)} />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{side === 'buy' ? 'Estimated cost' : 'Estimated proceeds'}</span>
<strong>{money(shares * h.price)}</strong>
</div>
<span>{side === 'buy' ? 'Cash available ' + money(CASH) : 'You hold ' + h.shares + ' shares'}</span>
</div>
<MdButton slot="actions" variant="text" onMdClick={() => setOpen(false)}>Cancel</MdButton>
<MdButton slot="actions" variant="filled" onMdClick={() => setOpen(false)}>
{(side === 'buy' ? 'Buy ' : 'Sell ') + shares + ' ' + symbol}
</MdButton>
</MdBottomSheet>
</div>
);
}import { Component, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MdBottomSheet, MdButton, MdChip, MdLineChart, MdNumberField, MdSegmentedButton,
MdSegmentedButtonSet, MdSlider, MdSparkline, MdStatusDot, MdTable, MdTableBody,
MdTableCell, MdTableContainer, MdTableHead, MdTableRow, NumericValueAccessor
} from '@awc-ui/angular';
const CASH = 12400;
const DAYS = ['Thu', 'Fri', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const HOLDINGS: any[] = [
{ symbol: 'AAPL', name: 'Apple Inc.', price: 232.10, shares: 40, value: '$9,284.00',
gain: '+18.4%', color: 'success', spark: [221.4, 224.1, 219.8, 226.5, 229.9, 228.4, 232.1] },
{ symbol: 'MSFT', name: 'Microsoft Corp.', price: 456.80, shares: 25, value: '$11,420.00',
gain: '+9.6%', color: 'success', spark: [449.2, 452.6, 447.1, 455.3, 451.8, 458.9, 456.8] },
{ symbol: 'INTC', name: 'Intel Corp.', price: 21.48, shares: 150, value: '$3,222.00',
gain: '-12.3%', color: 'error', spark: [23.1, 22.6, 22.9, 22.1, 21.8, 21.95, 21.48] }
];
const RANGES: any = {
'1w': { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], values: [106910, 107420, 106310, 107880, 108136] },
'1m': { labels: ['Jul 25', 'Aug 1', 'Aug 8', 'Aug 15', 'Aug 22'], values: [103240, 105150, 104480, 106620, 108136] },
'1y': { labels: ['Sep', 'Nov', 'Jan', 'Mar', 'May', 'Jul', 'Aug'], values: [84200, 90400, 92300, 91800, 98400, 103240, 108136] }
};
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@Component({
selector: 'app-portfolio-markets',
standalone: true,
imports: [
FormsModule, NumericValueAccessor, MdBottomSheet, MdButton, MdChip, MdLineChart,
MdNumberField, MdSegmentedButton, MdSegmentedButtonSet, MdSlider, MdSparkline,
MdStatusDot, MdTable, MdTableBody, MdTableCell, MdTableContainer, MdTableHead, MdTableRow
],
template:
'<div class="portfolio">' +
' <header>' +
' <div>' +
' <span>Portfolio value</span> <strong>$108,136.40</strong>' +
' <md-chip variant="assist" appearance="filled" color="success" [density]="-1" label="+$1,142.18 today"></md-chip>' +
' <!-- the dot stays decorative — the adjacent words carry the session state -->' +
' <span class="session"><md-status-dot state="online" size="small"></md-status-dot> NYSE — Market open, closes 4:00 PM ET</span>' +
' </div>' +
' <md-segmented-button-set aria-label="Chart range" [density]="-1" (mdChange)="onRange($event)">' +
' @for (s of segments; track s.value) {' +
' <md-segmented-button [value]="s.value" [label]="s.label" [selected]="range === s.value"></md-segmented-button>' +
' }' +
' </md-segmented-button-set>' +
' </header>' +
' <md-line-chart area curve="monotone" legend="none" grid="horizontal" heightProp="240px"' +
' summary="Portfolio value over the selected range."' +
' [xAxis]="axis" [series]="series" [valueFormatter]="fmtK"></md-line-chart>' +
' <md-table-container>' +
' <md-table label="Holdings" columnTemplate="1.1fr 0.8fr 1fr 0.9fr 0.9fr 0.7fr" minWidth="640px">' +
' <md-table-head><md-table-row rowgroup="head">' +
' <md-table-cell head scope="col">Symbol</md-table-cell>' +
' <md-table-cell head scope="col" numeric>Last</md-table-cell>' +
' <md-table-cell head scope="col">7 days</md-table-cell>' +
' <md-table-cell head scope="col" numeric>Value</md-table-cell>' +
' <md-table-cell head scope="col">Gain/loss</md-table-cell>' +
' <md-table-cell head scope="col"></md-table-cell>' +
' </md-table-row></md-table-head>' +
' <md-table-body>' +
' @for (h of holdings; track h.symbol) {' +
' <md-table-row [value]="h.symbol">' +
' <md-table-cell><strong>{{ h.symbol }}</strong> {{ h.name }}</md-table-cell>' +
' <md-table-cell numeric>{{ money(h.price) }}</md-table-cell>' +
' <md-table-cell><md-sparkline [data]="h.spark" [labels]="days" [color]="h.color" [showTooltip]="false" heightProp="24px"></md-sparkline></md-table-cell>' +
' <md-table-cell numeric>{{ h.value }}</md-table-cell>' +
' <md-table-cell><md-chip variant="assist" appearance="filled" [color]="h.color" [density]="-2" [label]="h.gain"></md-chip></md-table-cell>' +
' <md-table-cell><md-button variant="text" (mdClick)="openTicket(h.symbol)">Trade</md-button></md-table-cell>' +
' </md-table-row>' +
' }' +
' </md-table-body>' +
' </md-table>' +
' </md-table-container>' +
'</div>' +
'<md-bottom-sheet [open]="open" [closeable]="true" [bottomDivider]="true" [headline]="headline" (mdClose)="open = false">' +
' <div class="ticket">' +
' <md-segmented-button-set aria-label="Order side" [density]="-1" (mdChange)="onSide($event)">' +
' @for (s of sideSegments; track s.value) {' +
' <md-segmented-button [value]="s.value" [label]="s.label" [selected]="side === s.value"></md-segmented-button>' +
' }' +
' </md-segmented-button-set>' +
' <span>Last {{ money(holding.price) }}</span>' +
' <md-number-field label="Shares" [min]="1" [max]="max" [step]="1" [(ngModel)]="shares"' +
' supportingText="Market order — executes at the next available price"' +
' (mdInput)="onType($event)"></md-number-field>' +
' <md-slider aria-label="Shares" [min]="1" [max]="max" [step]="1" [(ngModel)]="shares"' +
' [valueIndicator]="true" (mdInput)="onSlide($event)"></md-slider>' +
' <div class="estimate"><span>{{ estimateLabel }}</span> <strong>{{ estimate }}</strong></div>' +
' <span class="limit">{{ limitNote }}</span>' +
' </div>' +
' <md-button slot="actions" variant="text" (mdClick)="open = false">Cancel</md-button>' +
' <md-button slot="actions" variant="filled" (mdClick)="open = false">{{ submitLabel }}</md-button>' +
'</md-bottom-sheet>'
})
export class PortfolioMarketsComponent implements OnInit {
holdings = HOLDINGS;
days = DAYS;
segments = [{ value: '1w', label: '1W' }, { value: '1m', label: '1M' }, { value: '1y', label: '1Y' }];
sideSegments = [{ value: 'buy', label: 'Buy' }, { value: 'sell', label: 'Sell' }];
range = '1w';
symbol = 'AAPL';
side = 'buy';
shares = 10;
open = false;
axis: any = {};
series: any[] = [];
money = money;
fmtK = (v: number | null | undefined) => v == null ? '' : '$' + Math.round(v / 100) / 10 + 'k';
get holding() { return HOLDINGS.find((h) => h.symbol === this.symbol); }
get max() {
const h = this.holding;
return this.side === 'buy' ? Math.max(1, Math.floor(CASH / h.price)) : h.shares;
}
get headline() { return 'Trade ' + this.symbol + ' — ' + this.holding.name; }
get estimateLabel() { return this.side === 'buy' ? 'Estimated cost' : 'Estimated proceeds'; }
get estimate() { return money(this.shares * this.holding.price); }
get submitLabel() { return (this.side === 'buy' ? 'Buy ' : 'Sell ') + this.shares + ' ' + this.symbol; }
get limitNote() {
return this.side === 'buy'
? 'Cash available ' + money(CASH)
: 'You hold ' + this.holding.shares + ' shares';
}
ngOnInit() { this.applyRange('1w'); }
// series and xAxis are arrays/objects — property bindings, never attributes.
applyRange(key: string) {
const r = RANGES[key];
this.axis = { data: r.labels, scale: 'category' };
this.series = [{ label: 'Portfolio value', data: r.values, color: 'primary' }];
}
onRange(event: Event) {
const values = (event as CustomEvent<string[]>).detail;
if (values.length) { this.range = values[0]; this.applyRange(values[0]); }
}
openTicket(sym: string) {
this.symbol = sym;
this.side = 'buy';
this.shares = Math.min(10, this.max);
this.open = true;
}
onSide(event: Event) {
const values = (event as CustomEvent<string[]>).detail;
if (values.length) { this.side = values[0]; this.shares = Math.min(this.shares, this.max); }
}
// Live estimate while typing / dragging; ngModel itself syncs at the commit points.
onType(event: Event) {
const v = (event as CustomEvent<any>).detail.value;
if (v != null) this.shares = Math.min(Math.max(v, 1), this.max);
}
onSlide(event: Event) { this.shares = (event as CustomEvent<any>).detail.value; }
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdBottomSheet, MdButton, MdChip, MdLineChart, MdNumberField, MdSegmentedButton,
MdSegmentedButtonSet, MdSlider, MdSparkline, MdStatusDot, MdTable, MdTableBody,
MdTableCell, MdTableContainer, MdTableHead, MdTableRow
} from '@awc-ui/vue';
const CASH = 12400;
const DAYS = ['Thu', 'Fri', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const HOLDINGS = [
{ symbol: 'AAPL', name: 'Apple Inc.', price: 232.10, shares: 40, value: '$9,284.00',
gain: '+18.4%', color: 'success', spark: [221.4, 224.1, 219.8, 226.5, 229.9, 228.4, 232.1] },
{ symbol: 'MSFT', name: 'Microsoft Corp.', price: 456.80, shares: 25, value: '$11,420.00',
gain: '+9.6%', color: 'success', spark: [449.2, 452.6, 447.1, 455.3, 451.8, 458.9, 456.8] },
{ symbol: 'INTC', name: 'Intel Corp.', price: 21.48, shares: 150, value: '$3,222.00',
gain: '-12.3%', color: 'error', spark: [23.1, 22.6, 22.9, 22.1, 21.8, 21.95, 21.48] }
];
const RANGES: any = {
'1w': { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], values: [106910, 107420, 106310, 107880, 108136] },
'1m': { labels: ['Jul 25', 'Aug 1', 'Aug 8', 'Aug 15', 'Aug 22'], values: [103240, 105150, 104480, 106620, 108136] },
'1y': { labels: ['Sep', 'Nov', 'Jan', 'Mar', 'May', 'Jul', 'Aug'], values: [84200, 90400, 92300, 91800, 98400, 103240, 108136] }
};
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const range = ref('1w');
const symbol = ref('AAPL');
const side = ref('buy');
const qty = ref(10);
const open = ref(false);
// series and xAxis are arrays/objects — bound with :series / :x-axis, never attributes.
const axis = computed(() => ({ data: RANGES[range.value].labels, scale: 'category' }));
const series = computed(() => [{ label: 'Portfolio value', data: RANGES[range.value].values, color: 'primary' }]);
const holding = computed(() => HOLDINGS.find((h) => h.symbol === symbol.value)!);
const max = computed(() => side.value === 'buy'
? Math.max(1, Math.floor(CASH / holding.value.price))
: holding.value.shares);
const shares = computed(() => Math.min(Math.max(qty.value, 1), max.value));
const estimate = computed(() => money(shares.value * holding.value.price));
const submitLabel = computed(() => (side.value === 'buy' ? 'Buy ' : 'Sell ') + shares.value + ' ' + symbol.value);
const limitNote = computed(() => side.value === 'buy'
? 'Cash available ' + money(CASH)
: 'You hold ' + holding.value.shares + ' shares');
const fmtK = (v: number | null | undefined) => v == null ? '' : '$' + Math.round(v / 100) / 10 + 'k';
function onRange(e: CustomEvent<string[]>) { if (e.detail.length) range.value = e.detail[0]; }
function onSide(e: CustomEvent<string[]>) { if (e.detail.length) side.value = e.detail[0]; }
function onQty(e: CustomEvent<{ value: number | null }>) { if (e.detail.value != null) qty.value = e.detail.value; }
function onSlide(e: CustomEvent<{ value: number }>) { qty.value = e.detail.value; }
function openTicket(sym: string) {
const h = HOLDINGS.find((x) => x.symbol === sym)!;
symbol.value = sym;
side.value = 'buy';
qty.value = Math.min(10, Math.max(1, Math.floor(CASH / h.price)));
open.value = true;
}
</script>
<template>
<div class="portfolio">
<header>
<div>
<span>Portfolio value</span> <strong>$108,136.40</strong>
<MdChip variant="assist" appearance="filled" color="success" :density="-1" label="+$1,142.18 today" />
<!-- the dot stays decorative — the adjacent words carry the session state -->
<span class="session"><MdStatusDot state="online" size="small" /> NYSE — Market open, closes 4:00 PM ET</span>
</div>
<MdSegmentedButtonSet aria-label="Chart range" :density="-1" @md-change="onRange">
<MdSegmentedButton value="1w" label="1W" :selected="range === '1w'" />
<MdSegmentedButton value="1m" label="1M" :selected="range === '1m'" />
<MdSegmentedButton value="1y" label="1Y" :selected="range === '1y'" />
</MdSegmentedButtonSet>
</header>
<MdLineChart area curve="monotone" legend="none" grid="horizontal" height-prop="240px"
summary="Portfolio value over the selected range."
:x-axis="axis" :series="series" :value-formatter="fmtK" />
<MdTableContainer>
<MdTable label="Holdings" column-template="1.1fr 0.8fr 1fr 0.9fr 0.9fr 0.7fr" min-width="640px">
<MdTableHead>
<MdTableRow rowgroup="head">
<MdTableCell head scope="col">Symbol</MdTableCell>
<MdTableCell head scope="col" numeric>Last</MdTableCell>
<MdTableCell head scope="col">7 days</MdTableCell>
<MdTableCell head scope="col" numeric>Value</MdTableCell>
<MdTableCell head scope="col">Gain/loss</MdTableCell>
<MdTableCell head scope="col" />
</MdTableRow>
</MdTableHead>
<MdTableBody>
<MdTableRow v-for="h in HOLDINGS" :key="h.symbol" :value="h.symbol">
<MdTableCell><strong>{{ h.symbol }}</strong> {{ h.name }}</MdTableCell>
<MdTableCell numeric>{{ money(h.price) }}</MdTableCell>
<MdTableCell>
<MdSparkline :data="h.spark" :labels="DAYS" :color="h.color" :show-tooltip="false" height-prop="24px" />
</MdTableCell>
<MdTableCell numeric>{{ h.value }}</MdTableCell>
<MdTableCell><MdChip variant="assist" appearance="filled" :color="h.color" :density="-2" :label="h.gain" /></MdTableCell>
<MdTableCell><MdButton variant="text" @md-click="openTicket(h.symbol)">Trade</MdButton></MdTableCell>
</MdTableRow>
</MdTableBody>
</MdTable>
</MdTableContainer>
<MdBottomSheet :open="open" closeable bottom-divider :headline="'Trade ' + symbol + ' — ' + holding.name"
@md-close="open = false">
<div class="ticket">
<MdSegmentedButtonSet aria-label="Order side" :density="-1" @md-change="onSide">
<MdSegmentedButton value="buy" label="Buy" :selected="side === 'buy'" />
<MdSegmentedButton value="sell" label="Sell" :selected="side === 'sell'" />
</MdSegmentedButtonSet>
<span>Last {{ money(holding.price) }}</span>
<!-- both inputs render the same clamped state — programmatic writes are silent, so no loop -->
<MdNumberField label="Shares" :min="1" :max="max" :step="1" :value="shares"
supporting-text="Market order — executes at the next available price" @md-input="onQty" />
<MdSlider aria-label="Shares" :min="1" :max="max" :step="1" :value="shares" value-indicator
@md-input="onSlide" />
<div class="estimate">
<span>{{ side === 'buy' ? 'Estimated cost' : 'Estimated proceeds' }}</span>
<strong>{{ estimate }}</strong>
</div>
<span class="limit">{{ limitNote }}</span>
</div>
<MdButton slot="actions" variant="text" @md-click="open = false">Cancel</MdButton>
<MdButton slot="actions" variant="filled" @md-click="open = false">{{ submitLabel }}</MdButton>
</MdBottomSheet>
</div>
</template><script>
// The Svelte package ships the raw md-* elements — register them once on the client.
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window); // SvelteKit: if (browser) ...
import { onMount } from 'svelte';
const CASH = 12400;
const DAYS = ['Thu', 'Fri', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const HOLDINGS = [
{ symbol: 'AAPL', name: 'Apple Inc.', price: 232.10, shares: 40, value: '$9,284.00',
gain: '+18.4%', color: 'success', spark: [221.4, 224.1, 219.8, 226.5, 229.9, 228.4, 232.1] },
{ symbol: 'MSFT', name: 'Microsoft Corp.', price: 456.80, shares: 25, value: '$11,420.00',
gain: '+9.6%', color: 'success', spark: [449.2, 452.6, 447.1, 455.3, 451.8, 458.9, 456.8] },
{ symbol: 'INTC', name: 'Intel Corp.', price: 21.48, shares: 150, value: '$3,222.00',
gain: '-12.3%', color: 'error', spark: [23.1, 22.6, 22.9, 22.1, 21.8, 21.95, 21.48] }
];
const RANGES = {
'1w': { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], values: [106910, 107420, 106310, 107880, 108136] },
'1m': { labels: ['Jul 25', 'Aug 1', 'Aug 8', 'Aug 15', 'Aug 22'], values: [103240, 105150, 104480, 106620, 108136] },
'1y': { labels: ['Sep', 'Nov', 'Jan', 'Mar', 'May', 'Jul', 'Aug'], values: [84200, 90400, 92300, 91800, 98400, 103240, 108136] }
};
const money = (n) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
let range = '1w';
let symbol = 'AAPL';
let side = 'buy';
let qty = 10;
let chartEl;
let sheetEl;
let sparkEls = [];
$: holding = HOLDINGS.find((h) => h.symbol === symbol);
$: max = side === 'buy' ? Math.max(1, Math.floor(CASH / holding.price)) : holding.shares;
$: shares = Math.min(Math.max(qty, 1), max);
$: estimate = money(shares * holding.price);
$: submitLabel = (side === 'buy' ? 'Buy ' : 'Sell ') + shares + ' ' + symbol;
$: limitNote = side === 'buy'
? 'Cash available ' + money(CASH)
: 'You hold ' + holding.shares + ' shares';
// series and xAxis are arrays/objects — they cannot cross the attribute
// boundary, so they are assigned as element properties via bind:this.
function applyChart(key) {
if (!chartEl) return;
const r = RANGES[key];
chartEl.xAxis = { data: r.labels, scale: 'category' };
chartEl.series = [{ label: 'Portfolio value', data: r.values, color: 'primary' }];
}
function onRange(e) {
if (!e.detail.length) return;
range = e.detail[0];
applyChart(range);
}
function onSide(e) {
if (e.detail.length) side = e.detail[0];
}
function openTicket(sym) {
const h = HOLDINGS.find((x) => x.symbol === sym);
symbol = sym;
side = 'buy';
qty = Math.min(10, Math.max(1, Math.floor(CASH / h.price)));
sheetEl.show();
}
onMount(async () => {
await customElements.whenDefined('md-line-chart');
chartEl.valueFormatter = (v) => v == null ? '' : '$' + Math.round(v / 100) / 10 + 'k';
applyChart(range);
sparkEls.forEach((el, i) => {
if (el) { el.data = HOLDINGS[i].spark; el.labels = DAYS; }
});
});
</script>
<div class="portfolio">
<header>
<div>
<span>Portfolio value</span> <strong>$108,136.40</strong>
<md-chip variant="assist" appearance="filled" color="success" density="-1" label="+$1,142.18 today"></md-chip>
<!-- the dot stays decorative — the adjacent words carry the session state -->
<span class="session"><md-status-dot state="online" size="small"></md-status-dot> NYSE — Market open, closes 4:00 PM ET</span>
</div>
<md-segmented-button-set aria-label="Chart range" density="-1" on:mdChange={onRange}>
<md-segmented-button value="1w" label="1W" selected={range === '1w'}></md-segmented-button>
<md-segmented-button value="1m" label="1M" selected={range === '1m'}></md-segmented-button>
<md-segmented-button value="1y" label="1Y" selected={range === '1y'}></md-segmented-button>
</md-segmented-button-set>
</header>
<md-line-chart bind:this={chartEl} area curve="monotone" legend="none" grid="horizontal" height="240px"
summary="Portfolio value over the selected range."></md-line-chart>
<md-table-container>
<md-table label="Holdings" column-template="1.1fr 0.8fr 1fr 0.9fr 0.9fr 0.7fr" min-width="640px">
<md-table-head>
<md-table-row rowgroup="head">
<md-table-cell head scope="col">Symbol</md-table-cell>
<md-table-cell head scope="col" numeric>Last</md-table-cell>
<md-table-cell head scope="col">7 days</md-table-cell>
<md-table-cell head scope="col" numeric>Value</md-table-cell>
<md-table-cell head scope="col">Gain/loss</md-table-cell>
<md-table-cell head scope="col"></md-table-cell>
</md-table-row>
</md-table-head>
<md-table-body>
{#each HOLDINGS as h, i (h.symbol)}
<md-table-row value={h.symbol}>
<md-table-cell><strong>{h.symbol}</strong> {h.name}</md-table-cell>
<md-table-cell numeric>{money(h.price)}</md-table-cell>
<md-table-cell><md-sparkline bind:this={sparkEls[i]} color={h.color} show-tooltip="false" height="24px"></md-sparkline></md-table-cell>
<md-table-cell numeric>{h.value}</md-table-cell>
<md-table-cell><md-chip variant="assist" appearance="filled" color={h.color} density="-2" label={h.gain}></md-chip></md-table-cell>
<md-table-cell><md-button variant="text" on:mdClick={() => openTicket(h.symbol)}>Trade</md-button></md-table-cell>
</md-table-row>
{/each}
</md-table-body>
</md-table>
</md-table-container>
</div>
<md-bottom-sheet bind:this={sheetEl} closeable bottom-divider headline={'Trade ' + symbol + ' — ' + holding.name}>
<div class="ticket">
<md-segmented-button-set aria-label="Order side" density="-1" on:mdChange={onSide}>
<md-segmented-button value="buy" label="Buy" selected={side === 'buy'}></md-segmented-button>
<md-segmented-button value="sell" label="Sell" selected={side === 'sell'}></md-segmented-button>
</md-segmented-button-set>
<span>Last {money(holding.price)}</span>
<!-- both inputs render the same clamped state — programmatic writes are silent, so no loop -->
<md-number-field label="Shares" min="1" max={max} step="1" value={shares}
supporting-text="Market order — executes at the next available price"
on:mdInput={(e) => { if (e.detail.value != null) qty = e.detail.value; }}></md-number-field>
<md-slider aria-label="Shares" min="1" max={max} step="1" value={shares} value-indicator
on:mdInput={(e) => qty = e.detail.value}></md-slider>
<div class="estimate">
<span>{side === 'buy' ? 'Estimated cost' : 'Estimated proceeds'}</span>
<strong>{estimate}</strong>
</div>
<span class="limit">{limitNote}</span>
</div>
<md-button slot="actions" variant="text" on:mdClick={() => sheetEl.close()}>Cancel</md-button>
<md-button slot="actions" variant="filled" on:mdClick={() => sheetEl.close()}>{submitLabel}</md-button>
</md-bottom-sheet>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-line-chart | The portfolio trend. series and xAxis are JS properties (arrays never cross the attribute boundary), area fills under the line, and valueFormatter turns raw dollars into $108.1k ticks. |
md-segmented-button-set | The 1W/1M/3M/1Y range switch and the Buy/Sell toggle. Single-select mode is a radiogroup that can never be emptied, so e.detail[0] always holds exactly one value. |
md-table + container/head/body/row/cell | The hand-authored holdings grid. column-template declares the tracks, numeric right-aligns the money columns, min-width keeps the grid legible inside a scrolling container. |
md-sparkline | The 7-day micro-trend per row — show-tooltip="false" because the row already has an interactive Trade target. |
md-chip | Gain/loss badges. color takes a theme role name (success / error), which expands to the --md-sys-color-* pairs and re-themes with the tokens. |
md-status-dot | The market-session pip. Label left empty on purpose: the adjacent text carries the words, so the dot stays decorative (aria-hidden). |
md-bottom-sheet | The order ticket — modal, focus-trapped, Escape- and scrim-dismissible, with Cancel and the submit action in the actions slot behind bottom-divider. |
md-number-field | Share quantity, typed or stepped, min/max-clamped to what cash (or the position) allows. |
md-slider | The same quantity as a drag — value-indicator shows the count on the thumb, and the two inputs stay in lockstep. |
md-button | Row-level Trade triggers and the ticket’s actions; mdClick fires for pointer, Enter and Space alike. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The ticket’s bounds are real constraints, not hints.
md-number-fieldstepping (arrows, steppers, wheel) always clamps tomax, and a typed overshoot is clamped at commit — so “Buy 999” collapses to the 53 shares the cash balance covers before any order copy is rendered. - Programmatic writes can’t cause feedback loops. Setting
qty.valueorslider.valuefrom the other control’s event is a silent commit — neither component re-emits, which is what makes the two-way sync a four-line wiring job. - The bottom sheet is genuinely modal. It renders a scrim, traps focus,
locks body scroll and closes on a capture-phase Escape; slotted action
buttons never close it on their own, so Cancel and the submit button both
call
close()explicitly and a dismissal still firesmdCancel+mdClose. - The status dot follows the WCAG 1.4.1 contract. With no
labelit isrole="presentation"+aria-hidden, and the visible “Market open” text carries the meaning — the state never exists as colour alone. - The chart is keyboard-walkable. The plot is a focusable
role="application"region: arrow keys move point to point with polite live-region announcements, and a screen-reader-only data table backs the canvas — the range switch re-feedsserieswithout losing any of it.
Variations
Section titled “Variations”- Benchmark overlay: push a second series (
label: 'S&P 500',color: 'tertiary') and setlegend="top-end"— legend toggles survive every range re-feed, keyed by label. - Limit orders: add a second
md-number-fieldwithformat-options='{"style":"currency","currency":"USD"}'for the limit price; the raw number is what submits, the currency string is display-only. - After-hours state: swap the dot to
state="offline", change the text to “Market closed — opens 9:30 AM ET”, and setdisabledon the row Trade buttons so the ticket can’t open against a stale price. - Desktop ticket: at wide breakpoints move the order form into an
md-side-sheetso the holdings stay visible while the order is built — bottom sheets are a mobile pattern.
Related
Section titled “Related”- Recipe: KPI overview — the same range-switch-drives-charts wiring, applied to a metrics dashboard.
- Recipe: Server fleet status — status dots and per-row actions in a monitoring table.
- Component manuals:
md-line-chart,md-bottom-sheetandmd-number-field— full APIs, behavioral contracts, and anti-pattern lists.