Fixed some small bugs

This commit is contained in:
CodingPhoenixx
2026-02-13 17:36:02 +01:00
parent 479d7f1381
commit e6198508e7
7 changed files with 811 additions and 44 deletions
+214
View File
@@ -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>
`;
}
}
+272
View File
@@ -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>
`;
}
}
+139
View File
@@ -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>
`;
}
}
+29 -10
View File
@@ -1,6 +1,6 @@
// components/ui-badge.ts
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';
@@ -11,11 +11,11 @@ export class UiBadge extends LitElement {
display: inline-flex;
}
.badge {
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
@@ -23,18 +23,25 @@ export class UiBadge extends LitElement {
border-radius: 2rem;
border: 1px solid transparent;
white-space: nowrap;
}
line-height: 1;
}
.icon {
.icon {
display: inline-flex;
align-items: center;
}
flex-shrink: 0;
}
.icon ::slotted(svg) {
.icon ::slotted(svg) {
width: 0.85rem;
height: 0.85rem;
flex-shrink: 0;
}
}
.label {
display: inline-flex;
align-items: center;
padding-top: 0.05em;
}
.badge.accent {
color: var(--color-accent);
@@ -68,13 +75,25 @@ export class UiBadge extends LitElement {
`;
@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() {
return html`
<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>
</span>
</span>
`;
}
}
@@ -1,6 +1,6 @@
// components/ui-button-secondary.ts
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')
export class UiButtonSecondary extends LitElement {
@@ -52,10 +52,14 @@ export class UiButtonSecondary extends LitElement {
cursor: not-allowed;
}
.icon {
.icon {
display: inline-flex;
align-items: center;
}
}
.icon:not(:has(*)) {
display: none;
}
.icon ::slotted(svg) {
width: 1rem;
@@ -67,12 +71,21 @@ export class UiButtonSecondary extends LitElement {
@property({ type: Boolean }) disabled = 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() {
return html`
<button ?disabled=${this.disabled}>
<span class="icon"><slot name="icon"></slot></span>
<button ?disabled=${this.disabled}>
${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>
</button>
</button>
`;
}
}
+18 -7
View File
@@ -1,6 +1,6 @@
// components/ui-button.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('ui-button')
export class UiButton extends LitElement {
@@ -52,11 +52,14 @@ export class UiButton extends LitElement {
opacity: 0.55;
cursor: not-allowed;
}
.icon {
.icon {
display: inline-flex;
align-items: center;
}
}
.icon:not(:has(*)) {
display: none;
}
.icon ::slotted(svg) {
width: 1rem;
@@ -68,12 +71,20 @@ export class UiButton extends LitElement {
@property({ type: Boolean }) disabled = 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() {
return html`
<button ?disabled=${this.disabled}>
<span class="icon"><slot name="icon"></slot></span>
<button ?disabled=${this.disabled}>
${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>
</button>
</button>
`;
}
}
+99
View File
@@ -13,6 +13,9 @@ import '../components/form-input';
import '../components/horizontal-divider';
import '../components/notify-bar';
import '../components/ui-link';
import '../components/circle-chart';
import '../components/loading-bar';
import '../components/line-chart';
@customElement('dev-page')
export class DevPage extends LitElement {
@@ -571,6 +574,102 @@ export class DevPage extends LitElement {
</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>
`;
}