diff --git a/src/ak-progress/ak-progress.builder.ts b/src/ak-progress/ak-progress.builder.ts new file mode 100644 index 0000000..493f8c4 --- /dev/null +++ b/src/ak-progress/ak-progress.builder.ts @@ -0,0 +1,59 @@ +import "./ak-progress.component.js"; + +import { Progress, ProgressBarSize } from "./ak-progress.component.js"; + +import { html, nothing, TemplateResult } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +/* The `pick`ed fields here correspond to their types in the Progress class above. */ + +export interface ProgressProps extends Partial< + Pick< + Progress, + "variant" | "severity" | "min" | "max" | "value" | "showIcon" | "oneWay" | "displayValue" + > +> { + size?: ProgressBarSize; + label?: string | TemplateResult; + icon?: string | TemplateResult; +} + +/** + * @summary Helper function to create a Progress component programmatically + * + * @returns {TemplateResult} A Lit template result containing the configured ak-progress element + * + * @see {@link Progress} - The underlying web component + */ +export function akProgress(options: ProgressProps) { + const { + variant, + size, + severity, + min, + max, + value, + showIcon, + oneWay, + displayValue, + label, + icon, + } = options; + + return html` + + ${label ? html`
${label}
` : nothing} + ${icon ? html`
${icon}
` : nothing} +
+ `; +} diff --git a/src/ak-progress/ak-progress.component.ts b/src/ak-progress/ak-progress.component.ts new file mode 100644 index 0000000..6fb9c26 --- /dev/null +++ b/src/ak-progress/ak-progress.component.ts @@ -0,0 +1,195 @@ +import "../ak-icon/ak-icon.js"; + +import { AkLitElement } from "../component-base.js"; +import styles from "./ak-progress.css"; +import indeterminateAnimation from "./indeterminate-animation.css"; + +import { html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { styleMap } from "lit/directives/style-map.js"; + +export const progressBarVariants = ["none", "top", "inside", "outside", "indeterminate"] as const; +export type ProgressBarVariant = (typeof progressBarVariants)[number]; + +export const progressBarSize = ["xs", "sm", "lg"] as const; +export type ProgressBarSize = (typeof progressBarSize)[number]; + +export const progressBarSeverity = ["success", "danger", "warning"] as const; +export type ProgressBarSeverity = (typeof progressBarSeverity)[number]; + +export interface IProgress { + variant?: ProgressBarVariant; + _size?: ProgressBarSize; + severity?: ProgressBarSeverity; + min?: number; + max?: number; + value?: number; + showIcon?: boolean; + oneWay?: boolean; + displayValue?: (_: number) => string; +} + +const SEVERITY_ICONS = new Map([ + ["danger", "fa-times"], + ["warning", "fa-exclamation-triangle"], + ["success", "fa-check"], +]); + +/** + * @element ak-progress + * + * @summary A progress bar component that displays the completion progress of a task with + * customizable variants and severity states + * + * @attr {string} variant - Display variant: "none", "top", "inside", "outside" + * @attr {string} size - Size variant: "sm", "lg" + * @attr {string} severity - Severity state: "success", "danger", "warning" + * @attr {number} min - Minimum value for progress range + * @attr {number} max - Maximum value for progress range + * @attr {number} value - Current progress value + * @attr {boolean} show-icon - Shows severity icon when severity is set + * @attr {boolean} one-way - Prevents progress value from decreasing + * + * @slot label - Label text (renders in grid row 1, spans columns 1-2 for outside variant) + * @slot icon - Icon content (renders inline within status area) + * + * @remarks + * The component uses CSS Grid with specific positioning. Do not override override grid-column or + * grid-row properties in your slotted content as this will break the layout contract defined by + * PatternFly 5. + * + * @csspart main - The main container element + * @csspart status - Container for progress value and icon display + * @csspart status-icon - Container for the status icon element + * @csspart bar - The background progress bar container + * @csspart indicator - The filled portion of the progress bar + * @csspart measure - Text display of the current progress value + * @csspart label - Container for the label text + * + * @cssprop --pf-v5-c-progress--GridGap - Gap between grid elements + * @cssprop --pf-v5-c-progress__bar--Height - Height of the progress bar + * @cssprop --pf-v5-c-progress__bar--BackgroundColor - Background color of the progress bar + * @cssprop --pf-v5-c-progress__indicator--BackgroundColor - Color of the progress indicator + * @cssprop --pf-v5-c-progress__status--Gap - Gap between status elements + * @cssprop --pf-v5-c-progress__status-icon--Color - Color of the status icon + * @cssprop --pf-v5-c-progress--m-success__bar--BackgroundColor - Progress bar color for success state + * @cssprop --pf-v5-c-progress--m-warning__bar--BackgroundColor - Progress bar color for warning state + * @cssprop --pf-v5-c-progress--m-danger__bar--BackgroundColor - Progress bar color for danger state + * @cssprop --pf-v5-c-progress--m-success__status-icon--Color - Icon color for success state + * @cssprop --pf-v5-c-progress--m-warning__status-icon--Color - Icon color for warning state + * @cssprop --pf-v5-c-progress--m-danger__status-icon--Color - Icon color for danger state + * @cssprop --pf-v5-c-progress--m-inside__measure--Color - Text color for inside variant measure + * @cssprop --pf-v5-c-progress--m-inside__measure--FontSize - Font size for inside variant measure + * @cssprop --pf-v5-c-progress--m-outside__measure--FontSize - Font size for outside variant measure + * @cssprop --pf-v5-c-progress--m-sm__bar--Height - Height for small size variant + * @cssprop --pf-v5-c-progress--m-lg__bar--Height - Height for large size variant + * @cssprop --pf-v5-c-progress--m-lg__bar--Height - Height for large size variant + * @cssprop --ak-v1-c-progress--m-xs__bar--Height - Height for xs size variant + * @cssprop --ak-v1-c-progress--m-indeterminate--GridGap - Collapsed gap size for indeterminate variant + */ +export class Progress extends AkLitElement implements IProgress { + static readonly styles = [styles, indeterminateAnimation]; + + @property({ type: String }) + public variant: ProgressBarVariant = "top"; + + @property({ reflect: true }) + public severity?: ProgressBarSeverity; + + @property({ type: Number }) + public min = 0; + + @property({ type: Number }) + public max = 100; + + private _value = 0; + + @property({ type: Number }) + set value(value: number) { + if (this.oneWay && value < this._value) { + return; + } + this._value = value; + } + + public get value() { + return this._value; + } + + public reset() { + this._value = this.min; + this.requestUpdate(); + } + + @property({ type: Boolean, attribute: "show-icon" }) + public showIcon = false; + + @property({ type: Boolean, attribute: "one-way" }) + public oneWay = false; + + @property({ type: Object }) + public displayValue = (value: number) => `${value}%`; + + protected get renderedValue() { + return this.displayValue(this.value); + } + + protected get severityIcon() { + const icon = this.severity ? SEVERITY_ICONS.get(this.severity) : null; + return icon ? html`` : nothing; + } + + protected get percentage() { + if (this.max <= this.min) { + return this.value >= this.max ? 100 : 0; + } + + const range = this.max - this.min; + const normalized = Math.min(Math.max(this.value, this.min), this.max); + return ((normalized - this.min) / range) * 100; + } + + public override render() { + const width = + this.variant !== "indeterminate" + ? styleMap({ width: `${this.percentage}%` }) + : undefined; + const showIcon = this.hasSlotted("icon") || (this.severity !== undefined && this.showIcon); + const showStatus = this.variant !== "none" || showIcon; + + const label = this.hasSlotted("label") + ? html`` + : nothing; + + const measure = + showStatus && (this.variant === "top" || this.variant === "outside") + ? html`${this.renderedValue}` + : nothing; + + const selectIcon = () => + this.hasSlotted("icon") + ? html`` + : html`${this.severityIcon}`; + + const statusIcon = + showStatus && (this.hasSlotted("icon") || showIcon) ? selectIcon() : nothing; + + const status = showStatus + ? html`
${measure}${statusIcon}
` + : nothing; + + return html`
+ ${label} + + ${status} +
+
+ ${this.variant === "inside" + ? html`${this.renderedValue}` + : nothing} +
+
+
`; + } +} diff --git a/src/ak-progress/ak-progress.css b/src/ak-progress/ak-progress.css new file mode 100644 index 0000000..9a66dd9 --- /dev/null +++ b/src/ak-progress/ak-progress.css @@ -0,0 +1,217 @@ +:host { + --progress--GridGap: var(--pf-v5-c-progress--GridGap, 1rem); + --progress__bar--before--BackgroundColor: var( + --pf-v5-c-progress__bar--before--BackgroundColor, + #06c + ); + --progress__bar--Height: var(--pf-v5-c-progress__bar--Height, 1rem); + --progress__bar--BackgroundColor: var(--pf-v5-c-progress__bar--BackgroundColor, #fff); + --progress__measure--m-static-width--MinWidth: var( + --pf-v5-c-progress__measure--m-static-width--MinWidth, + 4.5ch + ); + --progress__status--Gap: var(--pf-v5-c-progress__status--Gap, 0.5rem); + --progress__status-icon--Color: var(--pf-v5-c-progress__status-icon--Color, #151515); + --progress__bar--before--Opacity: var(--pf-v5-c-progress__bar--before--Opacity, 0.2); + --progress__indicator--Height: var(--progress__bar--Height); + --progress__indicator--BackgroundColor: var( + --pf-v5-c-progress__indicator--BackgroundColor, + #06c + ); + --progress__helper-text--MarginTop: var( + --pf-v5-c-progress__helper-text--MarginTop, + calc(0.25rem - 1rem) + ); + --progress--m-success__bar--BackgroundColor: var( + --pf-v5-c-progress--m-success__bar--BackgroundColor, + #3e8635 + ); + --progress--m-warning__bar--BackgroundColor: var( + --pf-v5-c-progress--m-warning__bar--BackgroundColor, + #f0ab00 + ); + --progress--m-danger__bar--BackgroundColor: var( + --pf-v5-c-progress--m-danger__bar--BackgroundColor, + #c9190b + ); + --progress--m-success__status-icon--Color: var( + --pf-v5-c-progress--m-success__status-icon--Color, + #3e8635 + ); + --progress--m-warning__status-icon--Color: var( + --pf-v5-c-progress--m-warning__status-icon--Color, + #f0ab00 + ); + --progress--m-danger__status-icon--Color: var( + --pf-v5-c-progress--m-danger__status-icon--Color, + #c9190b + ); + --progress--m-inside__indicator--MinWidth: var( + --pf-v5-c-progress--m-inside__indicator--MinWidth, + 2rem + ); + --progress--m-inside__measure--Color: var(--pf-v5-c-progress--m-inside__measure--Color, #fff); + --progress--m-success--m-inside__measure--Color: var( + --pf-v5-c-progress--m-success--m-inside__measure--Color, + #fff + ); + --progress--m-warning--m-inside__measure--Color: var( + --pf-v5-c-progress--m-warning--m-inside__measure--Color, + #151515 + ); + --progress--m-inside__measure--FontSize: var( + --pf-v5-c-progress--m-inside__measure--FontSize, + 0.875rem + ); + --progress--m-outside__measure--FontSize: var( + --pf-v5-c-progress--m-outside__measure--FontSize, + 0.875rem + ); + --progress--m-sm__bar--Height: var(--pf-v5-c-progress--m-sm__bar--Height, 0.5rem); + --progress--m-sm__description--FontSize: var( + --pf-v5-c-progress--m-sm__description--FontSize, + 0.875rem + ); + --progress--m-sm__measure--FontSize: var(--pf-v5-c-progress--m-sm__measure--FontSize, 0.875rem); + --progress--m-lg__bar--Height: var(--pf-v5-c-progress--m-lg__bar--Height, 1.5rem); + --progress--m-xs__bar--Height: var(--ak-v1-c-progress--m-xs__bar--Height, 2px); + --progress--m-indeterminate--GridGap: var(--ak-v1-c-progress--m-indeterminate--GridGap, 0); +} + +:host(:not([hidden])) { + display: block; +} + +[part="main"] { + display: grid; + grid-template-rows: 1fr auto; + grid-template-columns: auto auto; + gap: var(--progress--GridGap); + align-items: end; +} + +:host([size="sm"]) { + --progress__bar--Height: var(--progress--m-sm__bar--Height); +} + +:host([size="lg"]) { + --progress__bar--Height: var(--progress--m-lg__bar--Height); +} + +:host([variant="inside"]) [part="indicator"] { + display: flex; + align-items: center; + justify-content: center; + min-width: var(--progress--m-inside__indicator--MinWidth); +} + +:host([variant="inside"]) [part="measure"] { + font-size: var(--progress--m-inside__measure--FontSize); + color: var(--progress--m-inside__measure--Color); + text-align: center; +} + +:host([size="sm"]) [part="measure"] { + font-size: var(--progress--m-sm__measure--FontSize); +} + +:host([size="sm"]) [part="description"] { + font-size: var(--progress--m-sm__description--FontSize); +} + +:host([variant="outside"]) [part="description"] { + grid-column: 1/3; +} + +:host([variant="outside"]) [part="measure"] { + display: inline-block; + font-size: var(--progress--m-outside__measure--FontSize); +} + +:host([variant="outside"]) [part="status"] { + grid-row: 2/3; + grid-column: 2/3; + align-self: center; +} + +:host([variant="outside"]) [part="bar"] { + grid-column: 1/2; +} + +:host([severity="success"]) { + --progress__bar--before--BackgroundColor: var(--progress--m-success__bar--BackgroundColor); + --progress__status-icon--Color: var(--progress--m-success__status-icon--Color); + --progress--m-inside__measure--Color: var(--progress--m-success--m-inside__measure--Color); +} + +:host([severity="warning"]) { + --progress__bar--before--BackgroundColor: var(--progress--m-warning__bar--BackgroundColor); + --progress__status-icon--Color: var(--progress--m-warning__status-icon--Color); + --progress--m-inside__measure--Color: var(--progress--m-warning--m-inside__measure--Color); +} + +:host([severity="danger"]) { + --progress__bar--before--BackgroundColor: var(--progress--m-danger__bar--BackgroundColor); + --progress__status-icon--Color: var(--progress--m-danger__status-icon--Color); +} + +[part="status-icon"] { + color: var(--progress__status-icon--Color); +} + +[part="indicator"] { + position: absolute; + height: var(--progress__indicator--Height); + background-color: var(--progress__indicator--BackgroundColor); +} + +[part="bar"] { + position: relative; + grid-row: 2/3; + grid-column: 1/3; + align-self: center; + height: var(--progress__bar--Height); + background-color: var(--progress__bar--BackgroundColor); + overflow: hidden; +} + +[part="status"] { + display: flex; + grid-row: 1/2; + grid-column: 2/3; + gap: var(--progress__status--Gap); + align-items: flex-start; + justify-content: flex-end; + text-align: end; + overflow-wrap: anywhere; +} + +[part="bar"]::before { + top: 0; + left: 0; + position: absolute; + width: 100%; + height: 100%; + content: ""; + background-color: var(--progress__bar--before--BackgroundColor); + opacity: var(--progress__bar--before--Opacity); +} + +:host([size="xs"]) { + --progress__bar--Height: var(--progress--m-xs__bar--Height); +} + +:host([variant="indeterminate"]) { + --progress--GridGap: var(--progress--m-indeterminate--GridGap); + + margin-bottom: calc(var(--progress__bar--Height) * -1); + z-index: 1; + position: relative; +} + +:host([variant="indeterminate"]) [part="indicator"] { + width: 100%; + height: 100%; + animation: indeterminate-animation 1s infinite linear; + transform-origin: 0% 50%; +} diff --git a/src/ak-progress/ak-progress.overridden.yaml b/src/ak-progress/ak-progress.overridden.yaml new file mode 100644 index 0000000..21de4bb --- /dev/null +++ b/src/ak-progress/ak-progress.overridden.yaml @@ -0,0 +1,52 @@ +import: progress.css +base: .pf-v5-c-progress +host: + :host(:not([hidden])): + $from: $base + $exclude: /./ + display: block + --progress__indicator--Height: var(--progress__bar--Height); + --progress--m-xs__bar--Height: var(--ak-v1-c-progress--m-xs__bar--Height, 2px); + --progress--m-indeterminate--GridGap: var(--ak-v1-c-progress--m-indeterminate--GridGap, 0); + '[part="main"]': + $from: $base + $exclude: /^--/ + :host([size="\1"]): + $from: /\.pf-m-(sm|lg)/ + :host([size="\1"]) [part="measure"]: + $from: /\.pf-m-(sm) .pf-v5-c-progress__measure/ + $exclude: word-break + :host([size="\1"]) [part="description"]: + $from: /\.pf-m-(sm) .pf-v5-c-progress__description/ + $exclude: word-break + :host([variant="\1"]) [part="\2"]: + $from: /\.pf-m-(inside|outside) \.pf-v5-c-progress__(indicator|measure|description)/ + :host([variant="\1"]) [part="status"]: + $from: /\.pf-m-(outside) \.pf-v5-c-progress__status/ + ':host([variant="\1"]) [part="\2"]%2': + $from: /\.pf-m-(outside) \.pf-v5-c-progress__(bar|indicator)/ + :host([severity="\1"]): + $from: /\.pf-m-(success|warning|danger)/ + '[part="\1"]': + $from: /__(indicator|status-icon)/ + '[part="bar"]': + $from: /__bar/ + overflow: hidden; + '[part="status"]': + $from: /__status/ + $exclude: /word-break/ + overflow-wrap: anywhere + '[part="\1"]::\2': + $from: /__(bar)::(before)/ + :host([size="xs"]): + --progress__bar--Height: var(--progress--m-xs__bar--Height); + :host([variant="indeterminate"]): + --progress--GridGap: var(--progress--m-indeterminate--GridGap); + margin-bottom: calc(var(--progress__bar--Height) * -1); + z-index: 1; + position: relative; + :host([variant="indeterminate"]) [part="indicator"]: + width: 100%; + height: 100%; + animation: indeterminate-animation 1s infinite linear; + transform-origin: 0% 50%; diff --git a/src/ak-progress/ak-progress.root.css b/src/ak-progress/ak-progress.root.css new file mode 100644 index 0000000..f5c1650 --- /dev/null +++ b/src/ak-progress/ak-progress.root.css @@ -0,0 +1,46 @@ +:root { + --pf-v5-c-progress--GridGap: var(--pf-v5-global--spacer--md); + --pf-v5-c-progress__bar--before--BackgroundColor: var(--pf-v5-global--primary-color--100); + --pf-v5-c-progress__bar--Height: var(--pf-v5-global--spacer--md); + --pf-v5-c-progress__bar--BackgroundColor: var(--pf-v5-global--BackgroundColor--light-100); + --pf-v5-c-progress__measure--m-static-width--MinWidth: 4.5ch; + --pf-v5-c-progress__status--Gap: var(--pf-v5-global--spacer--sm); + --pf-v5-c-progress__status-icon--Color: var(--pf-v5-global--Color--100); + --pf-v5-c-progress__bar--before--Opacity: 0.2; + --pf-v5-c-progress__indicator--Height: var(--pf-v5-c-progress__bar--Height); + --pf-v5-c-progress__indicator--BackgroundColor: var( + --pf-v5-c-progress__bar--before--BackgroundColor + ); + --pf-v5-c-progress__helper-text--MarginTop: var( + calc(--pf-v5-global--spacer--xs - --pf-v5-c-progress--GridGap) + ); + --pf-v5-c-progress--m-success__bar--BackgroundColor: var(--pf-v5-global--success-color--100); + --pf-v5-c-progress--m-warning__bar--BackgroundColor: var(--pf-v5-global--warning-color--100); + --pf-v5-c-progress--m-danger__bar--BackgroundColor: var(--pf-v5-global--danger-color--100); + --pf-v5-c-progress--m-success__status-icon--Color: var(--pf-v5-global--success-color--100); + --pf-v5-c-progress--m-warning__status-icon--Color: var(--pf-v5-global--warning-color--100); + --pf-v5-c-progress--m-danger__status-icon--Color: var(--pf-v5-global--danger-color--100); + --pf-v5-c-progress--m-inside__indicator--MinWidth: var(--pf-v5-global--spacer--xl); + --pf-v5-c-progress--m-inside__measure--Color: var(--pf-v5-global--Color--light-100); + --pf-v5-c-progress--m-success--m-inside__measure--Color: var(--pf-v5-global--Color--light-100); + --pf-v5-c-progress--m-warning--m-inside__measure--Color: var(--pf-v5-global--Color--dark-100); + --pf-v5-c-progress--m-inside__measure--FontSize: var(--pf-v5-global--FontSize--sm); + --pf-v5-c-progress--m-outside__measure--FontSize: var(--pf-v5-global--FontSize--sm); + --pf-v5-c-progress--m-sm__bar--Height: var(--pf-v5-global--spacer--sm); + --pf-v5-c-progress--m-sm__description--FontSize: var(--pf-v5-global--FontSize--sm); + --pf-v5-c-progress--m-sm__measure--FontSize: var(--pf-v5-global--FontSize--sm); + --pf-v5-c-progress--m-lg__bar--Height: var(--pf-v5-global--spacer--lg); + --ak-v1-c-progress--m-indeterminate--GridGap: 0; + --ak-v1-c-progress--m-xs__bar--Height: min(2px, calc(var(--pf-v5-global--spacer--md) / 8)); +} + +html[theme="dark"], +html[data-theme="dark"] { + --pf-v5-c-progress--m-inside__measure--Color: var(--pf-v5-global--palette--black-900); + --pf-v5-c-progress--m-success--m-inside__measure--Color: var( + --pf-v5-global--palette--black-900 + ); + --pf-v5-c-progress--m-warning--m-inside__measure--Color: var( + --pf-v5-global--palette--black-900 + ); +} diff --git a/src/ak-progress/ak-progress.stories.ts b/src/ak-progress/ak-progress.stories.ts new file mode 100644 index 0000000..325cc84 --- /dev/null +++ b/src/ak-progress/ak-progress.stories.ts @@ -0,0 +1,783 @@ +/* eslint-disable sonarjs/no-nested-conditional, sonarjs/pseudo-random */ + +import "./ak-progress.js"; +import "../ak-icon/ak-icon.js"; + +import { akProgress, Progress, ProgressBarSeverity, ProgressBarSize } from "./ak-progress.js"; + +import type { Meta, StoryObj } from "@storybook/web-components"; + +import { html } from "lit"; + +const meta: Meta = { + title: "Components/Progress", + component: "ak-progress", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "A progress bar component that displays the completion progress of a task. Supports multiple variants, sizes, and severity states.", + }, + }, + }, + argTypes: { + variant: { + control: { type: "select" }, + options: ["none", "top", "inside", "outside"], + description: "Position of the progress value display", + }, + _size: { + control: { type: "select" }, + options: ["sm", "lg"], + description: "Size variant of the progress bar", + }, + severity: { + control: { type: "select" }, + options: ["success", "danger", "warning"], + description: "Color theme indicating status severity", + }, + min: { + control: { type: "number" }, + description: "Minimum value", + }, + max: { + control: { type: "number" }, + description: "Maximum value", + }, + value: { + control: { type: "number" }, + description: "Current progress value", + }, + showIcon: { + control: { type: "boolean" }, + description: "Show severity icon when severity is set", + }, + }, + args: { + variant: "top", + min: 0, + max: 100, + value: 65, + showIcon: false, + }, + decorators: [ + (story) => + html`
+ ${story()} +
`, + ], +}; + +export default meta; +type Story = StoryObj; + +// Basic progress bar +export const Default: Story = { + parameters: { + docs: { + description: { + story: "Default progress bar with top variant showing percentage completion.", + }, + }, + }, +}; + +// All variants comparison +export const Variants: Story = { + parameters: { + docs: { + description: { + story: "Comparison of all progress bar variants: none, top, inside, and outside.", + }, + }, + }, + render: () => html` +
+
+

None (no value display)

+ +
+
+

Top (default)

+ Loading data... +
+
+

Inside

+ Processing files... +
+
+

Outside

+ Uploading... +
+
+

Indeterminate And Tiny

+ +
+
+ `, +}; + +// Severity states +export const SeverityStates: Story = { + parameters: { + docs: { + description: { + story: "Progress bars with different severity states and icons.", + }, + }, + }, + render: () => html` +
+
+

Success

+ + + +
+
+

Warning

+ + + +
+
+

Danger

+ + + +
+
+ `, +}; + +// Size variants +export const Sizes: Story = { + parameters: { + docs: { + description: { + story: "Progress bars in different sizes: small, default, and large.", + }, + }, + }, + render: () => html` +
+
+

Small

+ Compact progress +
+
+

Default

+ Standard progress +
+
+

Large

+ Prominent progress +
+
+ `, +}; + +// Custom value display +export const CustomValueDisplay: Story = { + parameters: { + docs: { + description: { + story: "Progress bar with custom value formatting function.", + }, + }, + }, + args: { + value: 45, + max: 200, + displayValue: (value: number) => `${value} of 200 items`, + }, + render: (args) => html` + + + + `, +}; + +// Custom range +export const CustomRange: Story = { + parameters: { + docs: { + description: { + story: "Progress bar with custom min/max range and value display.", + }, + }, + }, + render: () => html` +
+
+

Temperature range (°F)

+ `${value}°F`} + > + Current temperature (between freezing and boiling) + +
+
+

Score range

+ `${value}/500 points`} + > + Quiz score + +
+
+ `, +}; + +// With custom icon slot +export const WithCustomIcon: Story = { + parameters: { + docs: { + description: { + story: "Progress bar with custom icon in the icon slot.", + }, + }, + }, + render: () => html` + + + Downloading update... + + `, +}; + +// Animation simulation +export const AnimatedProgress: Story = { + parameters: { + docs: { + description: { + story: "Simulated animated progress bar (updates every second).", + }, + }, + }, + render: () => { + let progress = 0; + const updateProgress = () => { + const progressBar = document.querySelector("#animated-progress") as Progress; + if (progressBar) { + progress = (progress + 5) % 105; + progressBar.value = progress; + if (progress >= 100) { + progressBar.severity = "success"; + progressBar.showIcon = true; + } else { + progressBar.severity = undefined; + progressBar.showIcon = false; + } + } + }; + + // Start animation on story load + setTimeout(() => { + const interval = setInterval(updateProgress, 500); + // Clean up after 30 seconds + setTimeout(() => clearInterval(interval), 30000); + }, 100); + + return html` + + + `; + }, +}; + +// Edge cases +export const EdgeCases: Story = { + parameters: { + docs: { + description: { + story: "Edge cases: zero progress, complete progress, over-maximum and under-minimum values. The value property is clamped at render, but will reflect whatever the user has set internally (although see the one way demo for caveats). ", + }, + }, + }, + render: () => html` +
+
+

Zero progress

+ +
+
+

Complete

+ + +
+
+

Over maximum (should clamp to 100%)

+ +
+
+

Negative value (should show as 0%)

+ +
+
+ `, +}; + +// Complex layout combinations +export const ComplexLayouts: Story = { + parameters: { + docs: { + description: { + story: "Showing off a bit of mix-and-match of attributes and settings.", + }, + }, + }, + render: () => html` +
+
+

Small + Inside + Warning

+ + Compact warning + +
+
+

Large + Outside + Success

+ + Large success indicator + +
+
+

Default + None + Danger

+ + Silent danger + +
+
+

Small + Top + Custom Value

+ `Step ${Math.floor(v / 10) + 1}/10`} + > + Step progress + +
+
+ `, +}; + +export const InteractiveControls: Story = { + parameters: { + docs: { + description: { + story: "Demo for the one-way attribute, which prevents the progress meter from going backwards. In some cases, going backward might be... alarming. 😁", + }, + }, + }, + render: () => { + const handleIncrement = (id: string) => { + const progress = document.querySelector(`#${id}`) as Progress; + if (progress) { + progress.value = Math.min(progress.value + 5, progress.max); + } + }; + + const handleDecrement = (id: string) => { + const progress = document.querySelector(`#${id}`) as Progress; + if (progress) { + progress.value = Math.max(progress.value - 5, progress.min); + } + }; + + const handleReset = (id: string) => { + const progress = document.querySelector(`#${id}`) as Progress; + if (progress) { + progress.reset(); + } + }; + + return html` +
+
+

Normal Progress (bidirectional)

+ + Normal progress control + +
+ + + +
+
+ +
+

One-Way Progress (prevents decreases)

+ + One-way progress control + +
+ + + +
+

+ Note: The decrement button will modify the HTML attribute but the + visual progress won't decrease due to one-way protection. +

+
+
+ `; + }, +}; + +export const BuilderBasic: Story = { + parameters: { + docs: { + description: { + story: "Using the akProgress builder function for programmatic progress bar creation.", + }, + }, + }, + render: () => html` +
+ ${akProgress({ + value: 45, + label: "File upload progress", + })} + ${akProgress({ + variant: "inside", + value: 75, + severity: "warning", + showIcon: true, + label: "Validation warnings detected", + })} + ${akProgress({ + variant: "outside", + value: 100, + severity: "success", + showIcon: true, + label: html`Migration completed successfully!`, + })} +
+ `, +}; + +// Builder with dynamic content generation +export const BuilderDynamic: Story = { + parameters: { + docs: { + description: { + story: "Demonstrating dynamic progress bar generation using the builder function.", + }, + }, + }, + render: () => { + const tasks = [ + { name: "Database backup", progress: 100, status: "success" }, + { name: "File migration", progress: 67, status: "warning" }, + { name: "Index rebuild", progress: 23, status: undefined }, + { name: "Cache cleanup", progress: 0, status: undefined }, + ]; + + return html` +
+

System Maintenance Tasks

+ ${tasks.map((task) => + akProgress({ + value: task.progress, + severity: task.status as ProgressBarSeverity, + showIcon: !!task.status, + variant: "top", + label: html` +
+ ${task.name} + + ${task.progress === 0 + ? "Pending" + : task.progress === 100 + ? "Complete" + : "In Progress"} + +
+ `, + }), + )} +
+ `; + }, +}; + +// Builder with custom formatting +export const BuilderCustomFormatting: Story = { + parameters: { + docs: { + description: { + story: "Builder function with custom value display formatting and ranges.", + }, + }, + }, + render: () => html` +
+ ${akProgress({ + min: 0, + max: 500, + value: 387, + displayValue: (value: number) => `${value} MB / 500 MB`, + label: "Memory usage", + severity: (value) => (value > 400 ? "danger" : value > 300 ? "warning" : undefined), + })} + ${akProgress({ + min: -10, + max: 40, + value: 23, + displayValue: (value: number) => `${value}°C`, + variant: "inside", + label: "CPU Temperature", + })} + ${akProgress({ + min: 0, + max: 1000, + value: 847, + displayValue: (value: number) => { + if (value < 100) return `${value} items`; + if (value < 1000) return `${(value / 100).toFixed(1)}K items`; + return `${(value / 1000).toFixed(1)}M items`; + }, + variant: "outside", + label: "Items processed", + })} +
+ `, +}; + +// Builder with custom icons +export const BuilderCustomIcons: Story = { + parameters: { + docs: { + description: { + story: "Builder function demonstrating custom icon usage with different content types.", + }, + }, + }, + render: () => html` +
+ ${akProgress({ + value: 78, + variant: "top", + icon: html``, + label: "Cloud backup progress", + })} + ${akProgress({ + value: 34, + variant: "top", + icon: html` + + + + `, + label: "Custom SVG icon example", + })} + ${akProgress({ + value: 92, + variant: "inside", + severity: "success", + icon: html`🎉`, + label: "Celebration progress with emoji", + })} +
+ `, +}; + +// Builder reactive updates +export const BuilderReactive: Story = { + parameters: { + docs: { + description: { + story: "Demonstrating reactive updates with the builder function and one-way progress.", + }, + }, + }, + render: () => { + let normalProgress = 30; + let oneWayProgress = 30; + + const updateProgress = () => { + const container = document.querySelector("#reactive-container"); + if (!container) return; + + // Simulate fluctuating normal progress + normalProgress = Math.max( + 0, + Math.min(100, normalProgress + (Math.random() - 0.5) * 20), + ); + + // Simulate only increasing one-way progress + oneWayProgress = Math.min(100, oneWayProgress + Math.random() * 5); + + const normalEl = container.querySelector("#normal-reactive"); + const oneWayEl = container.querySelector("#oneway-reactive"); + + if (normalEl) { + (normalEl as Progress).value = normalProgress; + } + if (oneWayEl) { + (oneWayEl as Progress).value = oneWayProgress; + } + + // Update severity based on progress + if (normalEl) { + (normalEl as Progress).severity = + normalProgress > 80 + ? "success" + : normalProgress < 30 + ? "danger" + : normalProgress < 60 + ? "warning" + : undefined; + (normalEl as Progress).showIcon = normalProgress > 80 || normalProgress < 30; + } + }; + + // Start updates after render + setTimeout(() => { + const interval = setInterval(updateProgress, 1000); + setTimeout(() => clearInterval(interval), 15000); + }, 100); + + return html` +
+
+

Reactive Normal Progress

+ ${akProgress({ + value: normalProgress, + variant: "top", + label: "Fluctuating system metric", + })} +
+ +
+

Reactive One-Way Progress

+ ${akProgress({ + value: oneWayProgress, + variant: "inside", + oneWay: true, + label: "Download progress (one-way)", + })} +
+ +

+ These progress bars update automatically every second for 15 seconds. +

+
+ `; + }, +}; + +// Builder composition patterns +export const BuilderComposition: Story = { + parameters: { + docs: { + description: { + story: "Advanced composition patterns using the builder function in different layouts.", + }, + }, + }, + render: () => html` +
+
+

Dashboard Card Style

+
+ ${akProgress({ + variant: "none", + value: 73, + label: html` +
+ Server Load + 73% +
+ `, + })} +
+ Last updated: 2 minutes ago +
+
+
+ +
+

Inline Status Style

+
+
+ ${akProgress({ + variant: "outside", + value: 100, + severity: "success", + showIcon: true, + size: "sm" as ProgressBarSize, + label: "Backup completed", + })} + ${akProgress({ + variant: "outside", + value: 67, + severity: "warning", + showIcon: true, + size: "sm" as ProgressBarSize, + label: "Sync in progress", + })} + ${akProgress({ + variant: "outside", + value: 0, + size: "sm", + label: "Pending cleanup", + })} +
+
+
+
+ `, +}; diff --git a/src/ak-progress/ak-progress.ts b/src/ak-progress/ak-progress.ts new file mode 100644 index 0000000..8138662 --- /dev/null +++ b/src/ak-progress/ak-progress.ts @@ -0,0 +1,26 @@ +import { akProgress, type ProgressProps } from "./ak-progress.builder.js"; +import { + type IProgress, + Progress, + type ProgressBarSeverity, + type ProgressBarSize, + type ProgressBarVariant, +} from "./ak-progress.component.js"; + +export { + akProgress, + type IProgress, + Progress, + type ProgressBarSeverity, + type ProgressBarSize, + type ProgressBarVariant, + type ProgressProps, +}; + +window.customElements.define("ak-progress", Progress); + +declare global { + interface HTMLElementTagNameMap { + "ak-progress": Progress; + } +} diff --git a/src/ak-progress/indeterminate-animation.css b/src/ak-progress/indeterminate-animation.css new file mode 100644 index 0000000..f5575bf --- /dev/null +++ b/src/ak-progress/indeterminate-animation.css @@ -0,0 +1,13 @@ +@keyframes indeterminate-animation { + 0% { + transform: translateX(0) scaleX(0); + } + + 40% { + transform: translateX(0) scaleX(0.4); + } + + 100% { + transform: translateX(100%) scaleX(0.5); + } +} diff --git a/src/component-base.ts b/src/component-base.ts new file mode 100644 index 0000000..bd6f5da --- /dev/null +++ b/src/component-base.ts @@ -0,0 +1,29 @@ +import { LitElement } from "lit"; + +export class AkLitElement extends LitElement { + protected hasSlotted(name: string | null) { + const isNotNestedSlot = (start: Element) => { + let node = start.parentNode; + while (node && node !== this) { + if (node instanceof Element && node.hasAttribute("slot")) { + return false; + } + node = node.parentNode; + } + return true; + }; + + // All child slots accessible from the component's LightDOM that match the request + const allChildSlotRequests = + typeof name === "string" + ? [...this.querySelectorAll(`[slot="${name}"]`)] + : [...this.children].filter((child) => { + const slotAttr = child.getAttribute("slot"); + return !slotAttr || slotAttr === ""; + }); + + // All child slots accessible from the LightDom that match the request *and* are not nested + // within another slotted element. + return allChildSlotRequests.filter((node) => isNotNestedSlot(node)).length > 0; + } +} diff --git a/src/css/components/all-components.scss b/src/css/components/all-components.scss index cf6107b..61eb5bb 100644 --- a/src/css/components/all-components.scss +++ b/src/css/components/all-components.scss @@ -4,6 +4,8 @@ @import "../../ak-skip-to-content/ak-skip-to-content.root"; +@import "../../ak-progress/ak-progress.root"; + @import "../../ak-icon/ak-icon.root"; @import "../../ak-button/ak-button.root"; diff --git a/tools-src/build-wccss-sheets.mts b/tools-src/build-wccss-sheets.mts index 74d1f6f..c627399 100644 --- a/tools-src/build-wccss-sheets.mts +++ b/tools-src/build-wccss-sheets.mts @@ -457,7 +457,9 @@ async function buildStylesheets(transformationFiles: string[]) { const transformationsToPerform = Object.entries(transformation.host ?? {}); - transrule: for (const [transSelector, transRequest] of transformationsToPerform) { + transrule: for (const [rawTransSelector, transRequest] of transformationsToPerform) { + // Hack to permit duplicate selectors with different 'from' lines. + const transSelector = rawTransSelector.replace(/%\d+$/, ""); const selectorHasSubstitutions = /\\\d+/.test(transSelector); const customDeclarations = getCustomDeclarations(transRequest);