@hashsome/ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import { Flex, Typography } from 'e-prim';
3
3
  import { Icon } from '../icon.js';
4
4
  import { fallbackName } from '../entity-handle.js';
5
5
  import { useEntityHandle } from '../hooks.js';
6
+ import { AnimatedNumber } from '../layout/animated-number.js';
6
7
  import { SensorHistory } from '../layout/sensor-history.js';
7
8
  import { PlainButton } from '../layout/plain-button.js';
8
9
  import { DrawerTrigger } from '../layout/use-drawer.js';
@@ -62,20 +63,23 @@ export function SensorReadout({ entity, icon, name, battery, history, safeRange,
62
63
  const level = ready ? rangeLevel(sensor.value, range) : undefined;
63
64
  const label = name ?? sensor?.name ?? fallbackName(entity);
64
65
  const valueText = ready ? `${formatValue(sensor.value)}${unit ? ` ${unit}` : ''}` : '';
66
+ // A number that changes moves to its new value; written to one decimal, as `valueText` is.
67
+ const reading = (n) => `${formatValue(String(n))}${unit ? ` ${unit}` : ''}`;
68
+ const moving = ready && sensor.numeric !== undefined ? Math.round(sensor.numeric * 10) / 10 : undefined;
65
69
  const warning = level === 'low' || level === 'high';
66
70
  const hint = warning && range
67
71
  ? `${label} is too ${level} (safe range ${describeRange(range, unit)})`
68
72
  : undefined;
69
- const content = (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, size: 16 }), _jsx(Typography, { as: "span", variant: "body", noWrap: true, children: ready ? valueText : statusLabels[status] }), warning ? _jsx(Icon, { name: "lu:triangle-alert", size: 16 }) : null] }));
73
+ const content = (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, size: 16 }), _jsx(Typography, { as: "span", variant: "body", noWrap: true, children: ready ? (moving !== undefined ? (_jsx(AnimatedNumber, { value: moving, format: reading })) : (valueText)) : (statusLabels[status]) }), warning ? _jsx(Icon, { name: "lu:triangle-alert", size: 16 }) : null] }));
70
74
  const numeric = ready && sensor.numeric !== undefined;
71
75
  if (drawer && numeric) {
72
- return (_jsx(DrawerTrigger, { icon: resolvedIcon, label: label, kind: deviceClass ? capitalize(deviceClass) : 'Sensor', body: _jsx(SensorDetailBody, { valueText: valueText, level: level, range: range, unit: unit, ...(battery ? { battery } : {}), ...(typeof entity === 'string' ? { entity } : {}), ...(history ? { history } : {}) }), children: (open) => (_jsx(PlainButton, { inline: true, align: "center", gap: 1, css: readoutStates, "data-status": status, "data-range": level, "aria-label": `${label}: ${valueText}${hint ? `. ${hint}` : ''}`, title: hint, onClick: open, children: content })) }));
76
+ return (_jsx(DrawerTrigger, { icon: resolvedIcon, label: label, kind: deviceClass ? capitalize(deviceClass) : 'Sensor', body: _jsx(SensorDetailBody, { valueText: valueText, ...(moving !== undefined ? { moving, reading } : {}), level: level, range: range, unit: unit, ...(battery ? { battery } : {}), ...(typeof entity === 'string' ? { entity } : {}), ...(history ? { history } : {}) }), children: (open) => (_jsx(PlainButton, { inline: true, align: "center", gap: 1, css: readoutStates, "data-status": status, "data-range": level, "aria-label": `${label}: ${valueText}${hint ? `. ${hint}` : ''}`, title: hint, onClick: open, children: content })) }));
73
77
  }
74
78
  return (_jsx(Flex, { as: "span", inline: true, align: "center", gap: 1, css: readoutStates, "data-status": status, "data-range": level, title: hint, children: content }));
75
79
  }
76
- function SensorDetailBody({ valueText, level, range, unit, entity, battery, history, }) {
80
+ function SensorDetailBody({ valueText, moving, reading, level, range, unit, entity, battery, history, }) {
77
81
  const warning = level === 'low' || level === 'high';
78
- return (_jsxs(Flex, { direction: "column", gap: 2, children: [_jsxs(Flex, { direction: "column", background: "surfaceRaised", radius: "row", py: 3.5, px: 4, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "Now" }), _jsx(Typography, { as: "span", variant: "stat", children: valueText }), range ? (_jsxs(Flex, { align: "center", gap: 1.5, css: ({ palette }) => ({ color: warning ? palette.danger : palette.textMuted }), children: [warning ? _jsx(Icon, { name: "lu:triangle-alert", size: 14 }) : null, _jsx(Typography, { as: "span", variant: "secondary", children: warning
82
+ return (_jsxs(Flex, { direction: "column", gap: 2, children: [_jsxs(Flex, { direction: "column", background: "surfaceRaised", radius: "row", py: 3.5, px: 4, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "Now" }), _jsx(Typography, { as: "span", variant: "stat", children: moving !== undefined && reading ? (_jsx(AnimatedNumber, { value: moving, format: reading })) : (valueText) }), range ? (_jsxs(Flex, { align: "center", gap: 1.5, css: ({ palette }) => ({ color: warning ? palette.danger : palette.textMuted }), children: [warning ? _jsx(Icon, { name: "lu:triangle-alert", size: 14 }) : null, _jsx(Typography, { as: "span", variant: "secondary", children: warning
79
83
  ? `Too ${level} · safe range ${describeRange(range, unit)}`
80
84
  : `Within the safe range (${describeRange(range, unit)})` })] })) : null] }), battery ? _jsx(BatteryLevel, { entity: battery }) : null, _jsx(SensorHistory, { ...(history ? { samples: history } : entity ? { entity } : {}), unit: unit })] }));
81
85
  }
@@ -22,6 +22,7 @@ import { SensorReadout } from '../entities/sensor-readout.js';
22
22
  import { Clock, DateChip, PresenceStack, SystemStatus, TopBar, WeatherChip, } from '../entities/top-bar.js';
23
23
  import { Icon } from '../icon.js';
24
24
  import { EnergyChart, } from '../layout/energy-chart.js';
25
+ import { AnimatedNumber } from '../layout/animated-number.js';
25
26
  import { Board, Cell } from '../layout/board.js';
26
27
  import { Grid } from '../layout/grid.js';
27
28
  import { HistorySection } from '../layout/history-section.js';
@@ -74,6 +75,15 @@ function PropsTable({ name }) {
74
75
  function ComponentDoc({ title, description, components, children, }) {
75
76
  return (_jsxs("section", { css: { marginBottom: 40 }, children: [_jsxs(Flex, { direction: "column", gap: 1, maxWidth: "60ch", mb: 4, children: [_jsx(Typography, { as: "h2", variant: "heading", children: title }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: description })] }), _jsx(Stage, { children: children }), components.map((name) => (_jsx(PropsTable, { name: name }, name)))] }));
76
77
  }
78
+ /** A reading that changes every time the button is pressed, to see it move. */
79
+ function AnimatedNumberDemo() {
80
+ const [watts, setWatts] = useState(40.1);
81
+ return (_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Typography, { as: "span", variant: "stat", children: _jsx(AnimatedNumber, { value: watts, format: (n) => `${n.toFixed(1)} W` }) }), _jsx(ActionButton, { label: "Change the reading", icon: "lu:refresh-cw", onPress: () => {
82
+ // Down to a few watts, then back up to the lamp's own.
83
+ setWatts((current) => (current > 20 ? 3.2 : 38) + Math.round(Math.random() * 60) / 10);
84
+ return Promise.resolve();
85
+ } })] }));
86
+ }
77
87
  /** Plain dimmable tile whose fill follows a local value, since `Tile` itself is controlled. */
78
88
  function TileFillDemo() {
79
89
  const [fill, setFill] = useState(0.6);
@@ -364,7 +374,7 @@ export function Gallery({ density = 'comfortable' }) {
364
374
  boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)',
365
375
  transform: mode === 'dark' ? 'translate(28px, -50%)' : 'translateY(-50%)',
366
376
  transition: 'transform 0.2s ease',
367
- }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Board & Cell", components: ['Board', 'Cell'], description: "A page for one known device, laid out on a grid. Each cell says only how big it is (`cols` wide, `rows` tall); the board places them in order, in the first spot they fit. Rows are as tall as their content, so a cell of `rows={2}` ends where the second row does, and `rows='fill'` runs from where the cell is placed down to the bottom of the page, whatever rows are beside it. Every card is a whole number of grid modules tall, the gap between columns and inside a cell is three modules and between rows six, so the result stays on the grid (turn on the debug menu's Show grid to see it).", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Board, { children: [_jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: 2, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Stairs", icon: "lu:footprints" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Spotlights", icon: "lu:lightbulb" })] })] })] }), _jsx(Flex, { direction: "column", height: 460, css: { outline: '1px dashed currentColor' }, children: _jsxs(Board, { children: [_jsx(Cell, { children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: "fill", children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) })] }) })] }) }), _jsx(ComponentDoc, { title: "Top Row", components: ['TopRow'], description: "The row along the top of a page you build yourself instead of with `TopBar`: the date, the weather, the clock and the status in a line at the right, in the height the grid gives the top row (40px), with no padding of its own.", children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none. In a space narrower than 560px, or with `rows={2}`, the controls move to a second row under the track.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" }), _jsx(Flex, { width: 380, children: _jsx(MediaPlayerBar, { entity: "ha:media_player.living_room", rows: 2 }) })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull', 'MediaQueue'], description: "The big player: the player centered with its queue beside it (`MediaQueue`: tap a track to jump to it, the cross takes it out, Clear empties it), and its library below, where every track can be played, played next or added to the queue, and an open album or playlist can be played, shuffled or added whole. The media cards' drawers show the player and library too. Where the queue has no room beside the player (a narrow column, the side drawer), Library and Queue are tabs above the list; `tabs={false}` leaves only the library, `defaultTab` and `tab` / `onTabChange` choose which is open. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, queue: _jsx(MediaQueue, { entity: "ha:media_player.living_room" }), browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched; with `search='icon'` (the second demo) it is an icon at the end of the row of tabs, after a thin line, and pressing it turns that row into the field, so it takes no room of its own. The `theater` layout (third demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default. While a shelf loads, placeholders take the room the cards will (fourth demo, a shelf that never arrives), so nothing moves.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", search: "icon" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }), _jsx(MediaBrowser, { entity: "ha:media_player.loading", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Animated Outlet", components: ['AnimatedOutlet'], description: "A dashboard layout's `Outlet` with a transition between its pages: the page that was showing fades out, then the new one fades in, sliding from the side it sits on in the nav (a later page from the right, an earlier one from the left) when you give it the nav's `items`. Without them the pages only fade. It does nothing for a visitor who prefers reduced motion. Try the links.", children: _jsx(AnimatedOutletDemo, {}) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
377
+ }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Board & Cell", components: ['Board', 'Cell'], description: "A page for one known device, laid out on a grid. Each cell says only how big it is (`cols` wide, `rows` tall); the board places them in order, in the first spot they fit. Rows are as tall as their content, so a cell of `rows={2}` ends where the second row does, and `rows='fill'` runs from where the cell is placed down to the bottom of the page, whatever rows are beside it. Every card is a whole number of grid modules tall, the gap between columns and inside a cell is three modules and between rows six, so the result stays on the grid (turn on the debug menu's Show grid to see it).", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Board, { children: [_jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: 2, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Stairs", icon: "lu:footprints" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Spotlights", icon: "lu:lightbulb" })] })] })] }), _jsx(Flex, { direction: "column", height: 460, css: { outline: '1px dashed currentColor' }, children: _jsxs(Board, { children: [_jsx(Cell, { children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsxs(Cell, { cols: 8, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Lamp", icon: "lu:lightbulb" }), _jsx(Tile, { label: "Ceiling", icon: "lu:lightbulb" })] })] }), _jsx(Cell, { cols: 4, rows: "fill", children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.off" }) })] }) })] }) }), _jsx(ComponentDoc, { title: "Top Row", components: ['TopRow'], description: "The row along the top of a page you build yourself instead of with `TopBar`: the date, the weather, the clock and the status in a line at the right, in the height the grid gives the top row (40px), with no padding of its own.", children: _jsxs(TopRow, { children: [_jsx(DateChip, {}), _jsx(Clock, { format: "24h" })] }) }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Animated Number", components: ['AnimatedNumber'], description: "A number that moves to its new value instead of jumping: a power reading, a temperature. The first value is shown at once; a new one is moved to from what is shown now, even if the last move has not finished. The text is changed directly, with no render for each step, so many of them cost little, and nothing moves when the app is set to reduced motion. `format` writes a number with its unit and should round, as it is called for each step. The drawer's power reading, the sensor readouts and a thermostat's current temperature use it.", children: _jsx(AnimatedNumberDemo, {}) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none. In a space narrower than 560px, or with `rows={2}`, the controls move to a second row under the track.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" }), _jsx(Flex, { width: 380, children: _jsx(MediaPlayerBar, { entity: "ha:media_player.living_room", rows: 2 }) })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull', 'MediaQueue'], description: "The big player: the player centered with its queue beside it (`MediaQueue`: tap a track to jump to it, the cross takes it out, Clear empties it), and its library below, where every track can be played, played next or added to the queue, and an open album or playlist can be played, shuffled or added whole. The media cards' drawers show the player and library too. Where the queue has no room beside the player (a narrow column, the side drawer), Library and Queue are tabs above the list; `tabs={false}` leaves only the library, `defaultTab` and `tab` / `onTabChange` choose which is open. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, queue: _jsx(MediaQueue, { entity: "ha:media_player.living_room" }), browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched; with `search='icon'` (the second demo) it is an icon at the end of the row of tabs, after a thin line, and pressing it turns that row into the field, so it takes no room of its own. The `theater` layout (third demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default. While a shelf loads, placeholders take the room the cards will (fourth demo, a shelf that never arrives), so nothing moves.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", search: "icon" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }), _jsx(MediaBrowser, { entity: "ha:media_player.loading", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Animated Outlet", components: ['AnimatedOutlet'], description: "A dashboard layout's `Outlet` with a transition between its pages: the page that was showing fades out, then the new one fades in, sliding from the side it sits on in the nav (a later page from the right, an earlier one from the left) when you give it the nav's `items`. Without them the pages only fade. It does nothing for a visitor who prefers reduced motion. Try the links.", children: _jsx(AnimatedOutletDemo, {}) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
368
378
  { id: 'living-room', title: 'Living Room', icon: 'lu:sofa' },
369
379
  { id: 'bedroom', title: 'Bedroom', icon: 'lu:bed' },
370
380
  { id: 'bathroom', title: 'Bathroom', icon: 'tb:bath' },
@@ -18,6 +18,29 @@ export const COMPONENT_PROPS = {
18
18
  },
19
19
  ],
20
20
  },
21
+ AnimatedNumber: {
22
+ doc: 'A number that moves to its new value instead of jumping to it: a power reading that changes, a temperature. The first value is shown at once, and a new one is moved to from what is shown now, even if it has not arrived yet; the text is changed directly (no render per step), so many of them cost little, and none moves when the app is set to reduced motion.',
23
+ props: [
24
+ {
25
+ doc: 'The number to show. When it changes, what is shown moves to it instead of jumping.',
26
+ name: 'value',
27
+ optional: false,
28
+ type: 'number',
29
+ },
30
+ {
31
+ doc: 'How a number is written, with its unit: `(n) => \\`${n.toFixed(1)} W\\``. Called for every step of the move, with a number between the old value and the new one, so it should round. Default: the number with as many decimals as `value` has.',
32
+ name: 'format',
33
+ optional: true,
34
+ type: '(n: number) => string',
35
+ },
36
+ {
37
+ doc: 'Seconds the move takes. Default 0.6.',
38
+ name: 'duration',
39
+ optional: true,
40
+ type: 'number',
41
+ },
42
+ ],
43
+ },
21
44
  Board: {
22
45
  doc: 'A page laid out on a grid, for a dashboard made for one device. The page is `columns` equal columns (a gap between them) and rows as tall as what is in them, with twice that gap between rows. Nothing says where a cell goes: each `Cell` only says how big it is, and the board puts it in the first place it fits, in the order written. Every card is a whole number of grid modules tall and every gap is 3, so whatever the board makes stays on the grid. It takes the height that is left of the page. Opt in: a page of plain flex columns keeps working the same.',
23
46
  props: [
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from './status.ts';
8
8
  export type { IconName } from './icon-data.ts';
9
9
  export * from './icon.tsx';
10
10
  export * from './layout/grid.tsx';
11
+ export * from './layout/animated-number.tsx';
11
12
  export * from './layout/board.tsx';
12
13
  export * from './layout/top-row.tsx';
13
14
  export * from './layout/page.tsx';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from './hooks.js';
4
4
  export * from './status.js';
5
5
  export * from './icon.js';
6
6
  export * from './layout/grid.js';
7
+ export * from './layout/animated-number.js';
7
8
  export * from './layout/board.js';
8
9
  export * from './layout/top-row.js';
9
10
  export * from './layout/page.js';
@@ -0,0 +1,15 @@
1
+ export interface AnimatedNumberProps {
2
+ /** The number to show. When it changes, what is shown moves to it instead of jumping. */
3
+ value: number;
4
+ /** How a number is written, with its unit: `(n) => \`${n.toFixed(1)} W\``. Called for every step of the move, with a number between the old value and the new one, so it should round. Default: the number with as many decimals as `value` has. */
5
+ format?: (n: number) => string;
6
+ /** Seconds the move takes. Default 0.6. */
7
+ duration?: number;
8
+ }
9
+ /** How many decimals a number is written with: `40.1` has one. */
10
+ export declare function decimalsOf(value: number): number;
11
+ /** A number that moves to its new value instead of jumping to it: a power reading that changes, a
12
+ * temperature. The first value is shown at once, and a new one is moved to from what is shown now, even
13
+ * if it has not arrived yet; the text is changed directly (no render per step), so many of them cost
14
+ * little, and none moves when the app is set to reduced motion. */
15
+ export declare function AnimatedNumber({ value, format, duration }: AnimatedNumberProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { animate, MotionGlobalConfig } from 'motion/react';
4
+ import { useLayoutEffect, useRef } from 'react';
5
+ /** How many decimals a number is written with: `40.1` has one. */
6
+ export function decimalsOf(value) {
7
+ return Number.isFinite(value) ? (String(value).split('.')[1] ?? '').length : 0;
8
+ }
9
+ /** A number that moves to its new value instead of jumping to it: a power reading that changes, a
10
+ * temperature. The first value is shown at once, and a new one is moved to from what is shown now, even
11
+ * if it has not arrived yet; the text is changed directly (no render per step), so many of them cost
12
+ * little, and none moves when the app is set to reduced motion. */
13
+ export function AnimatedNumber({ value, format, duration = 0.6 }) {
14
+ const text = useRef(null);
15
+ const shown = useRef(value);
16
+ // What was asked for last, kept for the move, which must not start over because a caller's function is new.
17
+ const write = useRef((n) => String(n));
18
+ const decimals = decimalsOf(value);
19
+ const written = (n) => (format ? format(n) : n.toFixed(decimals));
20
+ useLayoutEffect(() => {
21
+ write.current = written;
22
+ });
23
+ // Before the first paint of a new value, so the final value is never seen for a frame first.
24
+ useLayoutEffect(() => {
25
+ const node = text.current;
26
+ const from = shown.current;
27
+ if (!node || from === value || !Number.isFinite(from) || !Number.isFinite(value)) {
28
+ shown.current = value;
29
+ return;
30
+ }
31
+ // Reduced motion: it is simply the new value.
32
+ if (MotionGlobalConfig.skipAnimations) {
33
+ shown.current = value;
34
+ node.textContent = write.current(value);
35
+ return;
36
+ }
37
+ node.textContent = write.current(from);
38
+ const move = animate(from, value, {
39
+ duration,
40
+ ease: 'easeOut',
41
+ onUpdate: (n) => {
42
+ shown.current = n;
43
+ node.textContent = write.current(n);
44
+ },
45
+ onComplete: () => {
46
+ shown.current = value;
47
+ node.textContent = write.current(value);
48
+ },
49
+ });
50
+ return () => move.stop();
51
+ }, [value, duration]);
52
+ return (_jsx("span", { ref: text, css: { fontVariantNumeric: 'tabular-nums' }, children: written(value) }));
53
+ }
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { Box, Flex, Typography } from 'e-prim';
4
- import { motion } from 'motion/react';
4
+ import { animate, motion } from 'motion/react';
5
5
  import { MarqueeText } from './marquee-text.js';
6
6
  import { useEffect, useId, useRef, } from 'react';
7
7
  import { Icon } from '../icon.js';
8
- import { FadeScroll } from './fade-scroll.js';
8
+ import { FADE, FadeScroll } from './fade-scroll.js';
9
9
  import { RoundButton } from './round-button.js';
10
10
  /** Fine adjustment: a press nudges by `delta` (1%); holding repeats in steps of 10 until released. */
11
11
  export function StepButton({ icon, label, delta, holdDelta = delta * 10, onStep, disabled, size = 36, }) {
@@ -104,6 +104,49 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
104
104
  // The accent behind the selected chip is one pill that glides to the next; its own id, so two rows
105
105
  // on a page do not share it.
106
106
  const pill = useId();
107
+ // The selected tab is brought into view when it is not (it is a long name that has just opened, or a
108
+ // tab part way out of the row), by scrolling the row so it is at its start.
109
+ const marker = useRef(null);
110
+ const selectedLabel = options.find((option) => option.value === value)?.label;
111
+ useEffect(() => {
112
+ const chip = marker.current?.parentElement;
113
+ const scroller = chip?.parentElement;
114
+ if (!tabs || !chip || !scroller) {
115
+ return;
116
+ }
117
+ let scroll;
118
+ const reveal = () => {
119
+ const at = chip.getBoundingClientRect();
120
+ const row = scroller.getBoundingClientRect();
121
+ // Out of view includes under the fade at an edge that has more beyond it, which hides it nearly as much.
122
+ const inLeft = row.left + (scroller.scrollLeft > 1 ? FADE : 0);
123
+ const more = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1;
124
+ const inRight = row.right - (more ? FADE : 0);
125
+ if (at.left >= inLeft && at.right <= inRight) {
126
+ return;
127
+ }
128
+ scroll?.stop();
129
+ const from = scroller.scrollLeft;
130
+ // To the start of the row, clear of the fade there (a position before the start is the start).
131
+ scroll = animate(from, from + (at.left - row.left) - FADE, {
132
+ duration: 0.35,
133
+ ease: 'easeOut',
134
+ onUpdate: (to) => {
135
+ scroller.scrollLeft = to;
136
+ },
137
+ });
138
+ };
139
+ reveal();
140
+ // The row can get narrower after the tab is selected (the actions of what opened come in beside it).
141
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reveal);
142
+ resize?.observe(scroller);
143
+ return () => {
144
+ scroll?.stop();
145
+ resize?.disconnect();
146
+ };
147
+ // `selectedLabel` is not read inside: a new name for the selected tab is its length changing, which is a reason to look again.
148
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
149
+ }, [tabs, value, selectedLabel]);
107
150
  // Tabs scroll sideways when there are more than fit, and fade at the edge that has more.
108
151
  const Wrap = tabs ? FadeScroll : Flex;
109
152
  return (_jsx(Wrap, { gap: 2, ...(tabs ? { role: 'tablist' } : {}), css: tabs
@@ -123,7 +166,7 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
123
166
  position: 'relative',
124
167
  whiteSpace: 'nowrap',
125
168
  transition: 'color 160ms ease',
126
- }, children: [selected ? (_jsx(Box, { as: motion.span, layoutId: pill, position: "absolute", radius: "full", background: "accent", transition: { type: 'spring', duration: 0.32, bounce: 0.12 },
169
+ }, children: [selected ? _jsx("span", { ref: marker, hidden: true }) : null, selected ? (_jsx(Box, { as: motion.span, layoutId: pill, position: "absolute", radius: "full", background: "accent", transition: { type: 'spring', duration: 0.32, bounce: 0.12 },
127
170
  // Above every chip's own background, whichever way it is going: a later chip paints over
128
171
  // an earlier one's pill otherwise.
129
172
  css: { inset: 0, zIndex: 1 } })) : null, _jsxs(Flex, { as: "span", align: "center", gap: 1.5, position: "relative", minWidth: 0, css: { zIndex: 2 }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", css: { minWidth: 0, maxWidth: 220 }, children: _jsx(MarqueeText, { children: option.label }) })] })] }, option.value));
@@ -4,6 +4,7 @@ import { useState } from 'react';
4
4
  import { useEntityHandle } from '../hooks.js';
5
5
  import { useEntityHistory } from '../use-entity-history.js';
6
6
  import { usageFromDaily } from './energy-usage.js';
7
+ import { AnimatedNumber, decimalsOf } from './animated-number.js';
7
8
  import { useDetail } from './detail-provider.js';
8
9
  import { RangeSwitcher, SeriesChart } from './series-chart.js';
9
10
  const ENERGY_RANGES = [
@@ -53,9 +54,7 @@ function PowerReading({ entity, fallbackUnit }) {
53
54
  const handle = useEntityHandle('sensor', entity);
54
55
  const sensor = handle.entity;
55
56
  const unit = sensor?.unit ?? fallbackUnit;
56
- return (_jsx(Typography, { as: "span", variant: "stat", "aria-live": "off", children: handle.status === 'ready' && sensor?.numeric !== undefined
57
- ? `${sensor.numeric} ${unit}`
58
- : '—' }));
57
+ return (_jsx(Typography, { as: "span", variant: "stat", "aria-live": "off", children: handle.status === 'ready' && sensor?.numeric !== undefined ? (_jsx(AnimatedNumber, { value: sensor.numeric, format: (n) => `${n.toFixed(decimalsOf(sensor.numeric ?? 0))} ${unit}` })) : ('—') }));
59
58
  }
60
59
  function LifetimeEnergy({ entity }) {
61
60
  const handle = useEntityHandle('sensor', entity);
@@ -2,6 +2,8 @@
2
2
  import type { Interpolation, Theme } from '@emotion/react';
3
3
  import { type FlexProps } from 'e-prim';
4
4
  import { type ElementType } from 'react';
5
+ /** How wide the fade at a scrolled edge is. */
6
+ export declare const FADE = 40;
5
7
  /**
6
8
  * A row that scrolls sideways and fades out at an edge that has more content beyond it, so it
7
9
  * reads as scrollable: the right edge while there is more to the right, the left once scrolled.
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
2
  import { Flex } from 'e-prim';
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  /** How wide the fade at a scrolled edge is. */
5
- const FADE = 40;
5
+ export const FADE = 40;
6
6
  const maskFor = (start, end) => `linear-gradient(to right, ${start ? 'transparent' : '#000'} 0, #000 ${start ? FADE : 0}px, #000 calc(100% - ${end ? FADE : 0}px), ${end ? 'transparent' : '#000'} 100%)`;
7
7
  /**
8
8
  * A row that scrolls sideways and fades out at an edge that has more content beyond it, so it
@@ -16,7 +16,9 @@ export const iconButtonStyles = ({ palette, radius }) => ({
16
16
  transition: 'background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease',
17
17
  '&:disabled': { cursor: 'default', opacity: 0.5 },
18
18
  "&[data-active='true']": { color: palette.warm },
19
- "&[data-feedback='pending']": { opacity: 0.6 },
19
+ // Waiting: the ring turning around the edge says so, so the button stays as clear as it was.
20
+ "&[data-feedback='pending']": { position: 'relative', cursor: 'progress' },
21
+ "&:disabled[data-feedback='pending']": { cursor: 'progress', opacity: 1 },
20
22
  "&[data-feedback='done']": { background: palette.accent, color: palette.accentText },
21
23
  "&[data-feedback='error']": { color: palette.danger },
22
24
  "&[data-primary='true']": { background: palette.accent, color: palette.accentText },
@@ -0,0 +1,13 @@
1
+ /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
2
+ * app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
3
+ * small turning icon costs next to nothing. */
4
+ export declare function Spinner({ size, label }: {
5
+ size?: number;
6
+ label?: string;
7
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
8
+ /** A ring that turns around the edge of a round button (give the button `position: relative`): the
9
+ * button is waiting for an answer and will not take another press. Kept turning under reduced motion
10
+ * for the same reason `Spinner` is. */
11
+ export declare function SpinnerRing({ label }: {
12
+ label?: string;
13
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { keyframes } from '@emotion/react';
4
+ import { Icon } from '../icon.js';
5
+ const turn = keyframes({ to: { transform: 'rotate(360deg)' } });
6
+ /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
7
+ * app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
8
+ * small turning icon costs next to nothing. */
9
+ export function Spinner({ size = 16, label = 'Loading' }) {
10
+ return (_jsx("span", { role: "status", "aria-label": label, "data-keep-motion": true, css: {
11
+ display: 'inline-grid',
12
+ placeItems: 'center',
13
+ flex: 'none',
14
+ animation: `${turn} 0.9s linear infinite`,
15
+ }, children: _jsx(Icon, { name: "lu:loader-circle", size: size }) }));
16
+ }
17
+ /** A ring that turns around the edge of a round button (give the button `position: relative`): the
18
+ * button is waiting for an answer and will not take another press. Kept turning under reduced motion
19
+ * for the same reason `Spinner` is. */
20
+ export function SpinnerRing({ label = 'Loading' }) {
21
+ return (_jsx("span", { role: "status", "aria-label": label, "data-keep-motion": true, css: ({ palette }) => ({
22
+ position: 'absolute',
23
+ inset: 0,
24
+ pointerEvents: 'none',
25
+ color: palette.accent,
26
+ animation: `${turn} 0.9s linear infinite`,
27
+ }), children: _jsx("svg", { viewBox: "0 0 100 100", width: "100%", height: "100%", "aria-hidden": "true", children: _jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", stroke: "currentColor", strokeWidth: "5", strokeLinecap: "round", strokeDasharray: "75 221" }) }) }));
28
+ }
@@ -70,6 +70,6 @@ export declare function IconButton({ icon, label, onClick, disabled, active, pre
70
70
  color?: string;
71
71
  /** For a toggle: whether it is on, announced to assistive tech (`aria-pressed`). Pair it with `active` to color it. */
72
72
  pressed?: boolean;
73
- /** Shows the outcome of an action the press started: `pending` dims it, `done` flashes it with the accent, `error` turns it red. Every press also gives a small squeeze. */
73
+ /** Shows the outcome of an action the press started: `pending` turns a ring around it and stops it taking another press, `done` flashes it with the accent, `error` turns it red. Every press also gives a small squeeze. */
74
74
  feedback?: 'pending' | 'done' | 'error' | undefined;
75
75
  }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'motion/react';
4
4
  import { useEffect, useMemo, useRef, useState, } from 'react';
5
5
  import { Icon } from '../icon.js';
6
6
  import { RoundButton } from './round-button.js';
7
+ import { SpinnerRing } from './spinner.js';
7
8
  import { statusLabels } from '../status.js';
8
9
  import { BADGE } from '../theme/grid.js';
9
10
  import { useDrawer } from './use-drawer.js';
@@ -211,5 +212,6 @@ export function Tile({ label, icon, secondary, status = 'ready', active = false,
211
212
  }, children: [iconBadge, _jsx(Typography, { variant: "label", as: "span", noWrap: true, textOverflow: "ellipsis", position: "relative", grow: 1, minWidth: 0, children: label }), !ready ? (_jsx(Typography, { variant: "secondary", as: "span", noWrap: true, textOverflow: "ellipsis", color: "textMuted", position: "relative", css: { flex: 'none' }, children: statusLabels[status] })) : liveSecondary ? (_jsx(Typography, { variant: "secondary", as: "span", noWrap: true, textOverflow: "ellipsis", color: statusOnAccent ? 'onAccent' : dimColorKey, position: "relative", css: { flex: 'none' }, children: liveSecondary })) : null] })), trailing ? (_jsx(Flex, { align: "center", gap: 2, pr: 2.5, position: "relative", children: trailing })) : null] }));
212
213
  }
213
214
  export function IconButton({ icon, label, onClick, disabled, active, pressed, primary, size, glyph, color, feedback, }) {
214
- return (_jsx(RoundButton, { as: motion.button, ...(size !== undefined ? { size: size * 2 } : {}), ...(color ? { css: { '&&&': { color } } } : {}), whileTap: disabled ? {} : { scale: 0.88 }, transition: { duration: 0.12 }, "data-feedback": feedback, "aria-label": label, title: label, disabled: disabled, "data-active": active, "aria-pressed": pressed, "data-primary": primary, onClick: onClick, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(Box, { as: motion.span, css: { display: 'grid' }, initial: { rotate: -90, opacity: 0, scale: 0.6 }, animate: { rotate: 0, opacity: 1, scale: 1 }, exit: { rotate: 90, opacity: 0, scale: 0.6 }, transition: { duration: 0.18 }, children: _jsx(Icon, { name: icon, ...(glyph !== undefined ? { size: glyph } : size !== undefined ? { size } : {}) }) }, icon) }) }));
215
+ const ring = feedback === 'pending';
216
+ return (_jsxs(RoundButton, { as: motion.button, ...(size !== undefined ? { size: size * 2 } : {}), ...(color ? { css: { '&&&': { color } } } : {}), whileTap: disabled || feedback === 'pending' ? {} : { scale: 0.88 }, transition: { duration: 0.12 }, "data-feedback": feedback, "aria-label": label, title: label, disabled: disabled || feedback === 'pending', "data-active": active, "aria-pressed": pressed, "data-primary": primary, onClick: onClick, children: [_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(Box, { as: motion.span, css: { display: 'grid' }, initial: { rotate: -90, opacity: 0, scale: 0.6 }, animate: { rotate: 0, opacity: 1, scale: 1 }, exit: { rotate: 90, opacity: 0, scale: 0.6 }, transition: { duration: 0.18 }, children: _jsx(Icon, { name: icon, ...(glyph !== undefined ? { size: glyph } : size !== undefined ? { size } : {}) }) }, icon) }), ring ? _jsx(SpinnerRing, {}) : null] }));
215
217
  }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
  import { useEntity, useReconnects } from './hooks.js';
3
+ import { useMediaPending } from './entities/media-pending.js';
3
4
  import { useClient } from './provider.js';
4
5
  /** How often the queue is read again while it is on screen, to pick up what another app changed. */
5
6
  const POLL_MS = 15_000;
@@ -19,6 +20,8 @@ export function useMediaQueue(ref) {
19
20
  // What changes when the queue's window does: the track playing and the order it plays in.
20
21
  const track = player?.kind === 'mediaPlayer' ? player.media?.title : undefined;
21
22
  const shuffle = player?.kind === 'mediaPlayer' ? player.shuffle : undefined;
23
+ // Something added to or played from the library changes the queue without changing the track.
24
+ const asked = useMediaPending(ref).length;
22
25
  const has = player?.kind === 'mediaPlayer' && player.capabilities.queue;
23
26
  useEffect(() => {
24
27
  if (!ref || !has) {
@@ -32,9 +35,9 @@ export function useMediaQueue(ref) {
32
35
  current = false;
33
36
  clearInterval(timer);
34
37
  };
35
- // `track`, `shuffle`, `tick` and `reconnects` are not read inside: a change to any of them is the reason to read again.
38
+ // `track`, `shuffle`, `tick`, `reconnects` and `asked` are not read inside: a change to any of them is the reason to read again.
36
39
  // oxlint-disable-next-line react/exhaustive-effect-dependencies
37
- }, [client, ref, has, track, shuffle, tick, reconnects]);
40
+ }, [client, ref, has, track, shuffle, tick, reconnects, asked]);
38
41
  const answered = state !== undefined && state.ref === ref;
39
42
  return {
40
43
  queue: answered ? state.queue : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Design system components and hooks",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@emotion/react": "11.14.0",
19
- "@hashsome/core": "0.6.0",
19
+ "@hashsome/core": "0.8.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",