Fixed some small bugs
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
import { LitElement, html, css, svg } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
export interface CircleSegment {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('circle-chart')
|
||||||
|
export class CircleChart extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
background: var(--color-bg-nav);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
transition: border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper:hover {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-accent) 40%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-container {
|
||||||
|
position: relative;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-label {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-text {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 45%, transparent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle {
|
||||||
|
transition:
|
||||||
|
stroke-dasharray 0.6s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.25s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 65%, transparent);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-value {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property() heading = '';
|
||||||
|
@property() centerText = 'Total';
|
||||||
|
@property({ type: Array }) segments: CircleSegment[] = [];
|
||||||
|
|
||||||
|
private defaultColors = [
|
||||||
|
'var(--color-accent)',
|
||||||
|
'#30a46c',
|
||||||
|
'#e79d13',
|
||||||
|
'#e5484d',
|
||||||
|
'#6e56cf',
|
||||||
|
'#0091ff',
|
||||||
|
];
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const total = this.segments.reduce((s, d) => s + d.value, 0);
|
||||||
|
const radius = 62;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const gapAngle = 0.02;
|
||||||
|
const totalGap = gapAngle * this.segments.length;
|
||||||
|
const usable = Math.max(0, circumference * (1 - totalGap));
|
||||||
|
const gapSize = circumference * gapAngle;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const arcs = this.segments.map((seg, i) => {
|
||||||
|
const pct = total > 0 ? seg.value / total : 0;
|
||||||
|
const dash = pct * usable;
|
||||||
|
const gap = circumference - dash;
|
||||||
|
const color =
|
||||||
|
seg.color || this.defaultColors[i % this.defaultColors.length];
|
||||||
|
const currentOffset = offset;
|
||||||
|
offset += dash + gapSize;
|
||||||
|
|
||||||
|
return svg`
|
||||||
|
<circle
|
||||||
|
cx="80" cy="80" r="${radius}"
|
||||||
|
fill="none"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-width="16"
|
||||||
|
stroke-dasharray="${dash} ${gap}"
|
||||||
|
stroke-dashoffset="${-currentOffset}"
|
||||||
|
/>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
${this.heading
|
||||||
|
? html`<h3 class="title">${this.heading}</h3>`
|
||||||
|
: null}
|
||||||
|
<div class="chart-body">
|
||||||
|
<div class="svg-container">
|
||||||
|
<svg viewBox="0 0 160 160">${arcs}</svg>
|
||||||
|
<div class="center-label">
|
||||||
|
<span class="center-value">${total}</span>
|
||||||
|
<span class="center-text">${this.centerText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
${this.segments.map(
|
||||||
|
(seg, i) => html`
|
||||||
|
<div class="legend-item">
|
||||||
|
<span
|
||||||
|
class="legend-dot"
|
||||||
|
style="background: ${seg.color ||
|
||||||
|
this.defaultColors[
|
||||||
|
i % this.defaultColors.length
|
||||||
|
]}"
|
||||||
|
></span>
|
||||||
|
<span class="legend-label">${seg.label}</span>
|
||||||
|
<span class="legend-value">${seg.value}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { LitElement, html, css, svg } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
export interface LineSeries {
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('line-chart')
|
||||||
|
export class LineChart extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper {
|
||||||
|
background: var(--color-bg-nav);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
transition: border-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrapper:hover {
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-accent) 40%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 45%, transparent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
aspect-ratio: 500 / 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.grid-line {
|
||||||
|
stroke: var(--color-border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: color-mix(in srgb, var(--color-text) 40%, transparent);
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-path {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-path:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area-path {
|
||||||
|
opacity: 0.08;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line {
|
||||||
|
width: 1rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 60%, transparent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property() heading = '';
|
||||||
|
@property() subtitle = '';
|
||||||
|
@property() xLabel = 'X';
|
||||||
|
@property() yLabel = 'Y';
|
||||||
|
@property({ type: Array }) series: LineSeries[] = [];
|
||||||
|
@property({ type: Boolean }) showArea = false;
|
||||||
|
|
||||||
|
private defaultColors = [
|
||||||
|
'var(--color-accent)',
|
||||||
|
'#30a46c',
|
||||||
|
'#e79d13',
|
||||||
|
'#e5484d',
|
||||||
|
'#6e56cf',
|
||||||
|
'#0091ff',
|
||||||
|
];
|
||||||
|
|
||||||
|
private padding = { top: 20, right: 25, bottom: 40, left: 45 };
|
||||||
|
private width = 500;
|
||||||
|
private height = 300;
|
||||||
|
|
||||||
|
private niceScale(min: number, max: number, ticks: number) {
|
||||||
|
const range = max - min || 1;
|
||||||
|
const rough = range / ticks;
|
||||||
|
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||||
|
const norm = rough / mag;
|
||||||
|
let step: number;
|
||||||
|
if (norm <= 1.5) step = 1 * mag;
|
||||||
|
else if (norm <= 3) step = 2 * mag;
|
||||||
|
else if (norm <= 7) step = 5 * mag;
|
||||||
|
else step = 10 * mag;
|
||||||
|
const nMin = Math.floor(min / step) * step;
|
||||||
|
const nMax = Math.ceil(max / step) * step;
|
||||||
|
return { min: nMin, max: nMax, step };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const p = this.padding;
|
||||||
|
const w = this.width - p.left - p.right;
|
||||||
|
const h = this.height - p.top - p.bottom;
|
||||||
|
|
||||||
|
const allPts = this.series.flatMap((s) => s.points);
|
||||||
|
const xs = allPts.map((d) => d.x);
|
||||||
|
const ys = allPts.map((d) => d.y);
|
||||||
|
const rawXMin = Math.min(...xs, 0);
|
||||||
|
const rawXMax = Math.max(...xs, 1);
|
||||||
|
const rawYMin = Math.min(...ys, 0);
|
||||||
|
const rawYMax = Math.max(...ys, 1);
|
||||||
|
|
||||||
|
const xScale = this.niceScale(rawXMin, rawXMax, 5);
|
||||||
|
const yScale = this.niceScale(rawYMin, rawYMax, 5);
|
||||||
|
|
||||||
|
const sx = (v: number) =>
|
||||||
|
p.left + ((v - xScale.min) / (xScale.max - xScale.min)) * w;
|
||||||
|
const sy = (v: number) =>
|
||||||
|
p.top +
|
||||||
|
h -
|
||||||
|
((v - yScale.min) / (yScale.max - yScale.min)) * h;
|
||||||
|
|
||||||
|
const gridLines = [];
|
||||||
|
for (
|
||||||
|
let val = yScale.min;
|
||||||
|
val <= yScale.max + yScale.step * 0.01;
|
||||||
|
val += yScale.step
|
||||||
|
) {
|
||||||
|
const y = sy(val);
|
||||||
|
gridLines.push(svg`
|
||||||
|
<line class="grid-line" x1="${p.left}" y1="${y}" x2="${this.width - p.right}" y2="${y}" />
|
||||||
|
<text class="axis-label" x="${p.left - 8}" y="${y + 3}" text-anchor="end">${Math.round(val)}</text>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
let val = xScale.min;
|
||||||
|
val <= xScale.max + xScale.step * 0.01;
|
||||||
|
val += xScale.step
|
||||||
|
) {
|
||||||
|
const x = sx(val);
|
||||||
|
gridLines.push(svg`
|
||||||
|
<text class="axis-label" x="${x}" y="${this.height - p.bottom + 18}" text-anchor="middle">${Math.round(val)}</text>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseLine = sy(yScale.min);
|
||||||
|
|
||||||
|
const lines = this.series.map((s, i) => {
|
||||||
|
const color =
|
||||||
|
s.color || this.defaultColors[i % this.defaultColors.length];
|
||||||
|
const sorted = [...s.points].sort((a, b) => a.x - b.x);
|
||||||
|
if (!sorted.length) return svg``;
|
||||||
|
|
||||||
|
const d = sorted
|
||||||
|
.map((pt, j) => `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const areaD =
|
||||||
|
`M${sx(sorted[0].x)},${baseLine} ` +
|
||||||
|
sorted.map((pt) => `L${sx(pt.x)},${sy(pt.y)}`).join(' ') +
|
||||||
|
` L${sx(sorted[sorted.length - 1].x)},${baseLine} Z`;
|
||||||
|
|
||||||
|
return svg`
|
||||||
|
${this.showArea ? svg`<path class="area-path" d="${areaD}" fill="${color}" />` : null}
|
||||||
|
<path class="line-path" d="${d}" stroke="${color}" />
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
${this.heading || this.subtitle
|
||||||
|
? html`
|
||||||
|
<div class="header">
|
||||||
|
${this.heading
|
||||||
|
? html`<h3 class="title">${this.heading}</h3>`
|
||||||
|
: null}
|
||||||
|
${this.subtitle
|
||||||
|
? html`<p class="subtitle">${this.subtitle}</p>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
<div class="svg-container">
|
||||||
|
<svg viewBox="0 0 ${this.width} ${this.height}">
|
||||||
|
${gridLines} ${lines}
|
||||||
|
<text class="axis-label" x="${this.width / 2}" y="${this.height - 4}" text-anchor="middle">
|
||||||
|
${this.xLabel}
|
||||||
|
</text>
|
||||||
|
<text class="axis-label" x="12" y="${this.height / 2}" text-anchor="middle"
|
||||||
|
transform="rotate(-90, 12, ${this.height / 2})">
|
||||||
|
${this.yLabel}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
${this.series.length > 1
|
||||||
|
? html`
|
||||||
|
<div class="legend">
|
||||||
|
${this.series.map(
|
||||||
|
(s, i) => html`
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-line" style="background: ${s.color ||
|
||||||
|
this.defaultColors[
|
||||||
|
i % this.defaultColors.length
|
||||||
|
]}"></span>
|
||||||
|
<span class="legend-label">${s.label}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// components/loading-bar.ts
|
||||||
|
import { LitElement, html, css } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
@customElement('loading-bar')
|
||||||
|
export class LoadingBar extends LitElement {
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([size='sm']) .track {
|
||||||
|
height: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([size='lg']) .track {
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill.success {
|
||||||
|
background: #30a46c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill.warning {
|
||||||
|
background: #e79d13;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill.error {
|
||||||
|
background: #e5484d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([indeterminate]) .fill {
|
||||||
|
width: 40% !important;
|
||||||
|
animation: indeterminate 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(350%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.15) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ type: Number }) value = 0;
|
||||||
|
@property() label = '';
|
||||||
|
@property() variant: 'accent' | 'success' | 'warning' | 'error' =
|
||||||
|
'accent';
|
||||||
|
@property({ type: Boolean, reflect: true }) indeterminate = false;
|
||||||
|
@property({ type: Boolean }) hideValue = false;
|
||||||
|
@property({ reflect: true }) size: 'sm' | 'md' | 'lg' = 'md';
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const clamped = Math.max(0, Math.min(100, this.value));
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="wrapper">
|
||||||
|
${this.label || !this.hideValue
|
||||||
|
? html`
|
||||||
|
<div class="meta">
|
||||||
|
${this.label
|
||||||
|
? html`<span class="label">${this.label}</span>`
|
||||||
|
: html`<span></span>`}
|
||||||
|
${!this.hideValue && !this.indeterminate
|
||||||
|
? html`<span class="value">${clamped}%</span>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
<div class="track">
|
||||||
|
<div
|
||||||
|
class="fill ${this.variant}"
|
||||||
|
style="width: ${this.indeterminate ? 40 : clamped}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// components/ui-badge.ts
|
// components/ui-badge.ts
|
||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
export type BadgeVariant = 'accent' | 'success' | 'warning' | 'error' | 'muted';
|
export type BadgeVariant = 'accent' | 'success' | 'warning' | 'error' | 'muted';
|
||||||
|
|
||||||
@@ -11,11 +11,11 @@ export class UiBadge extends LitElement {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -23,18 +23,25 @@ export class UiBadge extends LitElement {
|
|||||||
border-radius: 2rem;
|
border-radius: 2rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.icon ::slotted(svg) {
|
.icon ::slotted(svg) {
|
||||||
width: 0.85rem;
|
width: 0.85rem;
|
||||||
height: 0.85rem;
|
height: 0.85rem;
|
||||||
flex-shrink: 0;
|
}
|
||||||
}
|
|
||||||
|
.label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
.badge.accent {
|
.badge.accent {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
@@ -68,13 +75,25 @@ export class UiBadge extends LitElement {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
@property() variant: BadgeVariant = 'accent';
|
@property() variant: BadgeVariant = 'accent';
|
||||||
|
@state() private hasIcon = false;
|
||||||
|
|
||||||
|
private handleSlotChange(e: Event) {
|
||||||
|
const slot = e.target as HTMLSlotElement;
|
||||||
|
this.hasIcon = slot.assignedNodes().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<span class="badge ${this.variant}">
|
<span class="badge ${this.variant}">
|
||||||
<span class="icon"><slot name="icon"></slot></span>
|
${this.hasIcon
|
||||||
|
? html`<span class="icon">
|
||||||
|
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
|
||||||
|
</span>`
|
||||||
|
: html`<slot name="icon" @slotchange=${this.handleSlotChange} style="display:none"></slot>`}
|
||||||
|
<span class="label">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// components/ui-button-secondary.ts
|
// components/ui-button-secondary.ts
|
||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
@customElement('ui-button-secondary')
|
@customElement('ui-button-secondary')
|
||||||
export class UiButtonSecondary extends LitElement {
|
export class UiButtonSecondary extends LitElement {
|
||||||
@@ -52,10 +52,14 @@ export class UiButtonSecondary extends LitElement {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon:not(:has(*)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon ::slotted(svg) {
|
.icon ::slotted(svg) {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
@@ -67,12 +71,21 @@ export class UiButtonSecondary extends LitElement {
|
|||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
@property({ type: Boolean, reflect: true }) full = false;
|
@property({ type: Boolean, reflect: true }) full = false;
|
||||||
|
|
||||||
|
@state() private hasIcon = false;
|
||||||
|
|
||||||
|
private handleSlotChange(e: Event) {
|
||||||
|
const slot = e.target as HTMLSlotElement;
|
||||||
|
this.hasIcon = slot.assignedNodes().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<button ?disabled=${this.disabled}>
|
<button ?disabled=${this.disabled}>
|
||||||
<span class="icon"><slot name="icon"></slot></span>
|
${this.hasIcon ? html`<span class="icon">
|
||||||
|
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
|
||||||
|
</span>` : html`<slot name="icon" @slotchange=${this.handleSlotChange} style="display:none"></slot>`}
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// components/ui-button.ts
|
// components/ui-button.ts
|
||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
@customElement('ui-button')
|
@customElement('ui-button')
|
||||||
export class UiButton extends LitElement {
|
export class UiButton extends LitElement {
|
||||||
@@ -52,11 +52,14 @@ export class UiButton extends LitElement {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.icon {
|
||||||
.icon {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon:not(:has(*)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon ::slotted(svg) {
|
.icon ::slotted(svg) {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
@@ -68,12 +71,20 @@ export class UiButton extends LitElement {
|
|||||||
@property({ type: Boolean }) disabled = false;
|
@property({ type: Boolean }) disabled = false;
|
||||||
@property({ type: Boolean, reflect: true }) full = false;
|
@property({ type: Boolean, reflect: true }) full = false;
|
||||||
|
|
||||||
|
@state() private hasIcon = false;
|
||||||
|
|
||||||
|
private handleSlotChange(e: Event) {
|
||||||
|
const slot = e.target as HTMLSlotElement;
|
||||||
|
this.hasIcon = slot.assignedNodes().length > 0;
|
||||||
|
}
|
||||||
render() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<button ?disabled=${this.disabled}>
|
<button ?disabled=${this.disabled}>
|
||||||
<span class="icon"><slot name="icon"></slot></span>
|
${this.hasIcon ? html`<span class="icon">
|
||||||
|
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
|
||||||
|
</span>` : html`<slot name="icon" @slotchange=${this.handleSlotChange} style="display:none"></slot>`}
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,9 @@ import '../components/form-input';
|
|||||||
import '../components/horizontal-divider';
|
import '../components/horizontal-divider';
|
||||||
import '../components/notify-bar';
|
import '../components/notify-bar';
|
||||||
import '../components/ui-link';
|
import '../components/ui-link';
|
||||||
|
import '../components/circle-chart';
|
||||||
|
import '../components/loading-bar';
|
||||||
|
import '../components/line-chart';
|
||||||
|
|
||||||
@customElement('dev-page')
|
@customElement('dev-page')
|
||||||
export class DevPage extends LitElement {
|
export class DevPage extends LitElement {
|
||||||
@@ -571,6 +574,102 @@ export class DevPage extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Loading Bars</h2>
|
||||||
|
<p>Progress indicators with variants and states</p>
|
||||||
|
</div>
|
||||||
|
<div class="component-group">
|
||||||
|
<div class="component-label">Variants</div>
|
||||||
|
<div class="component-preview col">
|
||||||
|
<loading-bar label="Accent" value="72"></loading-bar>
|
||||||
|
<loading-bar label="Success" value="100" variant="success"></loading-bar>
|
||||||
|
<loading-bar label="Warning" value="45" variant="warning"></loading-bar>
|
||||||
|
<loading-bar label="Error" value="18" variant="error"></loading-bar>
|
||||||
|
</div>
|
||||||
|
<div class="component-label">Sizes</div>
|
||||||
|
<div class="component-preview col">
|
||||||
|
<loading-bar label="Small" value="60" size="sm"></loading-bar>
|
||||||
|
<loading-bar label="Medium" value="60" size="md"></loading-bar>
|
||||||
|
<loading-bar label="Large" value="60" size="lg"></loading-bar>
|
||||||
|
</div>
|
||||||
|
<div class="component-label">Indeterminate</div>
|
||||||
|
<div class="component-preview col">
|
||||||
|
<loading-bar label="Loading…" indeterminate hideValue></loading-bar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Line Chart</h2>
|
||||||
|
<p>Trend lines with optional area fill</p>
|
||||||
|
</div>
|
||||||
|
<div class="component-group">
|
||||||
|
<div class="component-label">Multi-Series</div>
|
||||||
|
<div class="component-preview col">
|
||||||
|
<line-chart
|
||||||
|
heading="Score Progression"
|
||||||
|
subtitle="Last 6 Rounds"
|
||||||
|
xLabel="Round"
|
||||||
|
yLabel="Score"
|
||||||
|
showArea
|
||||||
|
.series=${[
|
||||||
|
{
|
||||||
|
label: 'Pilot A',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 420 },
|
||||||
|
{ x: 1, y: 420 },
|
||||||
|
{ x: 2, y: 580 },
|
||||||
|
{ x: 3, y: 540 },
|
||||||
|
{ x: 4, y: 710 },
|
||||||
|
{ x: 5, y: 690 },
|
||||||
|
{ x: 6, y: 820 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pilot B',
|
||||||
|
color: '#30a46c',
|
||||||
|
points: [
|
||||||
|
{ x: 1, y: 380 },
|
||||||
|
{ x: 2, y: 450 },
|
||||||
|
{ x: 3, y: 620 },
|
||||||
|
{ x: 4, y: 590 },
|
||||||
|
{ x: 5, y: 750 },
|
||||||
|
{ x: 6, y: 780 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></line-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Circle Chart</h2>
|
||||||
|
<p>Donut chart for proportional data</p>
|
||||||
|
</div>
|
||||||
|
<div class="component-group">
|
||||||
|
<div class="component-label">Example</div>
|
||||||
|
<div class="component-preview col">
|
||||||
|
<circle-chart
|
||||||
|
heading="Pilots by Category"
|
||||||
|
centerText="Pilots"
|
||||||
|
.segments=${[
|
||||||
|
{ label: 'Sport', value: 124 },
|
||||||
|
{ label: 'Serial', value: 89 },
|
||||||
|
{ label: 'Open', value: 47 },
|
||||||
|
{ label: 'Tandem', value: 18 },
|
||||||
|
]}
|
||||||
|
></circle-chart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user