Add battery details popover

This commit is contained in:
SirBlob committed 2026-10-31 18:41:16 -04:00
1 parent 0c53bad968
commit 950a38aad8
3 files changed
+282 -15

No files matched your search

+33 -11
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import { batteryAt, batteryIcon, type BatteryState } from '#lib/desktop/battery';
import { batteryAt, batteryIcon, isBatteryLow, type BatteryState } from '#lib/desktop/battery';
import BatteryPopover from './BatteryPopover.svelte';
let battery = $state<BatteryState | null>(null);
let open = $state(false);
const isLow = $derived(!!battery && battery.level < 20 && !battery.charging);
const isLow = $derived(!!battery && isBatteryLow(battery));
onMount(() => {
battery = batteryAt(new Date());
@@ -15,23 +17,43 @@
</script>
{#if battery}
<span
class="battery"
class:low={isLow}
class:charging={battery.charging}
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
>
<Icon icon={batteryIcon(battery)} width="18" />
<span class="level">{battery.level}%</span>
</span>
<div class="battery-widget">
<button
class="battery"
class:low={isLow}
class:charging={battery.charging}
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Icon icon={batteryIcon(battery)} width="18" />
<span class="level">{battery.level}%</span>
</button>
{#if open}
<BatteryPopover {battery} onclose={() => (open = false)} />
{/if}
</div>
{/if}
<style>
.battery-widget {
position: relative;
}
.battery {
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: var(--bar-height);
padding: 0 0.4rem;
background: none;
border: none;
color: var(--foreground);
}
.battery:hover {
color: var(--accent);
}
.charging {
@@ -0,0 +1,197 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import {
batteryCapacityWh,
batteryHealth,
batteryIcon,
batteryStatusLabel,
formatDuration,
isBatteryFull,
isBatteryLow,
powerRateLabel,
type BatteryState
} from '#lib/desktop/battery';
let { battery, onclose }: { battery: BatteryState; onclose: () => void } = $props();
const isFull = $derived(isBatteryFull(battery));
const isLow = $derived(isBatteryLow(battery));
const timeLeftLabel = $derived(
`${formatDuration(battery.minutesRemaining)} ${battery.charging ? 'until full' : 'left'}`
);
const details = $derived([
{ label: 'Battery size', value: `${batteryCapacityWh}Wh` },
{ label: 'Health', value: `${batteryHealth(battery)}%` },
{ label: 'Charge cycles', value: `${battery.chargeCycles}` },
{ label: 'Power', value: isFull ? '-' : powerRateLabel(battery) }
]);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Battery details">
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
<header class="summary">
<span class="battery-icon">
<Icon icon={batteryIcon(battery)} width="30" />
</span>
<div class="summary-text">
<span class="title">Battery</span>
<span class="status">
{batteryStatusLabel(battery)}
{#if !isFull}
<span class="time-left">· {timeLeftLabel}</span>
{/if}
</span>
</div>
<span class="level">{battery.level}%</span>
</header>
<div class="level-track">
<div class="level-fill" style:width="{battery.level}%"></div>
</div>
<dl class="details">
{#each details as detail (detail.label)}
<div class="detail-row">
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
{/each}
</dl>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: none;
border: none;
cursor: default;
}
.popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 50;
width: 340px;
padding: var(--border-size);
background: var(--border-active);
animation: window-pop-in 0.15s ease-out;
}
.popover-surface {
--state-color: var(--accent);
background: var(--background);
}
.popover-surface.charging {
--state-color: var(--green);
}
.popover-surface.low {
--state-color: var(--red);
}
.summary {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
}
.battery-icon {
display: grid;
place-items: center;
color: var(--state-color);
}
.summary-text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.title {
font-size: 1.05rem;
font-weight: 700;
}
.status {
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.level {
font-size: 1.9rem;
font-weight: 700;
}
.level-track {
height: 8px;
margin: 0 1rem;
background: var(--lighter-background);
}
.level-fill {
height: 100%;
background: var(--state-color);
transition: width 0.3s ease;
}
.details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1.25rem;
margin: 0;
padding: 1rem;
font-size: 0.8rem;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
white-space: nowrap;
}
.detail-row dt {
color: var(--muted);
}
.detail-row dd {
margin: 0;
}
.time-left {
color: var(--foreground);
text-transform: none;
letter-spacing: normal;
}
@media (max-width: 640px) {
.popover {
position: fixed;
top: calc(var(--bar-height) + 6px);
right: 8px;
left: 8px;
width: auto;
}
.details {
grid-template-columns: 1fr;
}
}
</style>