diff --git a/angular/demo/src/app/samples/progressbar/custom.route.ts b/angular/demo/src/app/samples/progressbar/custom.route.ts new file mode 100644 index 0000000000..8ac9ae7e95 --- /dev/null +++ b/angular/demo/src/app/samples/progressbar/custom.route.ts @@ -0,0 +1,83 @@ +import {ProgressbarComponent, ProgressbarContentDirective} from '@agnos-ui/angular'; +import {NgIf} from '@angular/common'; +import type {OnDestroy} from '@angular/core'; +import {Component} from '@angular/core'; +import type {Subscription} from 'rxjs'; +import {interval, map, startWith, takeWhile} from 'rxjs'; + +@Component({ + standalone: true, + imports: [ProgressbarComponent, ProgressbarContentDirective, NgIf], + template: ` +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+

+ {{ value === 0 ? 'Need to wake up.' : value < 100 ? 'Retrieving coffee... ' + value + '%' : 'Ready to work !' }} +

+
+
+ `, +}) +export default class DefaultProgressBarComponent implements OnDestroy { + value = 0; + subscription: Subscription | undefined; + + start() { + if (!this.subscription) { + this.subscription = interval(500) + .pipe( + startWith(-1), + map((val) => 10 * (val + 2)), + takeWhile((val) => val <= 100) + ) + .subscribe((val) => { + this.value = val; + }); + } + } + stop(reset = false) { + this.subscription?.unsubscribe(); + this.subscription = undefined; + if (reset) { + this.value = 0; + } + } + toggleProgress() { + if (this.subscription) { + this.stop(); + } else { + this.start(); + } + } + ngOnDestroy() { + this.stop(); + } +} diff --git a/angular/demo/src/app/samples/progressbar/default.route.ts b/angular/demo/src/app/samples/progressbar/default.route.ts new file mode 100644 index 0000000000..d51c9b2f56 --- /dev/null +++ b/angular/demo/src/app/samples/progressbar/default.route.ts @@ -0,0 +1,23 @@ +import {ProgressbarComponent, provideWidgetsConfig} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [ProgressbarComponent], + providers: [ + provideWidgetsConfig((config) => { + config.progressbar = {...config.progressbar, showPercentage: true}; + return config; + }), + ], + template: ` +
+
+
+
+
+
+
+ `, +}) +export default class DefaultProgressBarComponent {} diff --git a/angular/demo/src/app/samples/progressbar/playground.route.ts b/angular/demo/src/app/samples/progressbar/playground.route.ts new file mode 100644 index 0000000000..a843d42023 --- /dev/null +++ b/angular/demo/src/app/samples/progressbar/playground.route.ts @@ -0,0 +1,23 @@ +import type {RatingComponent} from '@agnos-ui/angular'; +import {ProgressbarComponent} from '@agnos-ui/angular'; +import {getProgressbarDefaultConfig} from '@agnos-ui/core'; +import {Component, ViewChild} from '@angular/core'; +import {getUndefinedValues, hashChangeHook, provideHashConfig} from '../../utils'; + +const undefinedConfig = getUndefinedValues(getProgressbarDefaultConfig()); + +@Component({ + standalone: true, + imports: [ProgressbarComponent], + providers: provideHashConfig('progressbar'), + template: `
`, +}) +export default class PlaygroundComponent { + @ViewChild('widget') widget: RatingComponent; + + constructor() { + hashChangeHook((props) => { + this.widget?._widget.patch({...undefinedConfig, ...props}); + }); + } +} diff --git a/angular/demo/src/app/samples/progressbar/striped.route.ts b/angular/demo/src/app/samples/progressbar/striped.route.ts new file mode 100644 index 0000000000..2aaca65d42 --- /dev/null +++ b/angular/demo/src/app/samples/progressbar/striped.route.ts @@ -0,0 +1,22 @@ +import {ProgressbarComponent} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [ProgressbarComponent], + template: ` +
+ A progressbar using custom values for minimum and maximum: +
Step 4 out of 5
+
+
+ A striped animated progress bar: +
+
+
+ Changing the height: +
+
+ `, +}) +export default class StripedProgressBarComponent {} diff --git a/angular/demo/src/assets/coffee-img-1.svg b/angular/demo/src/assets/coffee-img-1.svg new file mode 100644 index 0000000000..18f7ccf965 --- /dev/null +++ b/angular/demo/src/assets/coffee-img-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/angular/demo/src/assets/coffee-img-2.svg b/angular/demo/src/assets/coffee-img-2.svg new file mode 100644 index 0000000000..75d22bf607 --- /dev/null +++ b/angular/demo/src/assets/coffee-img-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/angular/demo/src/styles.css b/angular/demo/src/styles.css index 9dca10dc43..67eda5603f 100644 --- a/angular/demo/src/styles.css +++ b/angular/demo/src/styles.css @@ -1,3 +1,7 @@ /* You can add global styles to this file, and also import other style files */ @import 'bootstrap/dist/css/bootstrap.css'; + +.cup-fill { + background: url('assets/coffee-img-1.svg'), url('assets/coffee-img-2.svg'); +} diff --git a/angular/lib/src/lib/progressbar/progressbar.component.ts b/angular/lib/src/lib/progressbar/progressbar.component.ts new file mode 100644 index 0000000000..6686968599 --- /dev/null +++ b/angular/lib/src/lib/progressbar/progressbar.component.ts @@ -0,0 +1,141 @@ +import type {AdaptSlotContentProps, AdaptWidgetSlots, SlotContent} from '../slot.directive'; +import {ComponentTemplate, SlotDirective, callWidgetFactory} from '../slot.directive'; +import type {AfterContentChecked, OnChanges, Signal, SimpleChanges} from '@angular/core'; +import {NgClass, NgIf} from '@angular/common'; +import {ChangeDetectionStrategy, Component, ContentChild, Directive, Input, TemplateRef, ViewChild, inject} from '@angular/core'; +import type {ProgressbarContext as ProgressbarCoreContext, WidgetProps, WidgetState} from '@agnos-ui/core'; +import {createProgressbar, toSlotContextWidget} from '@agnos-ui/core'; +import {writable} from '@amadeus-it-group/tansu'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {patchSimpleChanges} from '../utils'; +import {SlotDefaultDirective} from '../slotDefault.directive'; + +export type ProgressbarWidget = AdaptWidgetSlots>; +export type ProgressbarState = WidgetState; +export type ProgressbarProps = WidgetProps; + +export type ProgressbarContext = AdaptSlotContentProps; + +@Directive({selector: 'ng-template[auProgressbarContent]', standalone: true}) +export class ProgressbarContentDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(_dir: ProgressbarContentDirective, context: unknown): context is ProgressbarCoreContext { + return true; + } +} + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgClass, NgIf, SlotDirective, ProgressbarContentDirective], + template: ` + +
+
+ + {{ state.percentage }}% +
+
+
+ `, +}) +export class ProgressbarDefaultSlotsComponent { + @ViewChild('content', {static: true}) content: TemplateRef; +} + +export const progressbarDefaultSlotContent = new ComponentTemplate(ProgressbarDefaultSlotsComponent, 'content'); + +const defaultConfig: Partial = { + slotContent: progressbarDefaultSlotContent, +}; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'div[au-progressbar]', + standalone: true, + imports: [SlotDirective, SlotDefaultDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + role: 'progressbar', + '[attr.aria-label]': 'state().ariaLabel || undefined', + '[attr.aria-valuenow]': 'state().value', + '[attr.aria-valuemin]': 'state().minimum', + '[attr.aria-valuemax]': 'state().maximum', + }, + template: ` + + + `, +}) +export class ProgressbarComponent implements AfterContentChecked, OnChanges { + readonly defaultSlots = writable(defaultConfig); + /** + * the aria label + */ + @Input() ariaLabel: string | undefined; + + /** + * the minimum value + */ + @Input() minimum: number | undefined; + + /** + * the maximum value + */ + @Input() maximum: number | undefined; + + /** + * the current value + */ + @Input() value: number | undefined; + + /** + * class to add to the content + */ + @Input() className: string | undefined; + + @Input() slotDefault: SlotContent>; + @Input() slotContent: SlotContent>; + @ContentChild(ProgressbarContentDirective, {static: false}) slotContentFromContent: ProgressbarContentDirective | undefined; + + /** + * if `true`, display the current percentage in the `xx%` format + */ + @Input() showPercentage: boolean | undefined; + + /** + * height of the progressbar, can be any valid css height value + */ + @Input() height: string | undefined; + + /** + * if `true`, animates a striped progressbar + */ + @Input() animated: boolean | undefined; + + /** + * if `true`, shows a striped progressbar + */ + @Input() striped: boolean | undefined; + + readonly _widget = callWidgetFactory(createProgressbar, 'progressbar', this.defaultSlots); + readonly widget = toSlotContextWidget(this._widget); + readonly api = this._widget.api; + readonly state: Signal = toSignal(this._widget.state$, {requireSync: true}); + + ngAfterContentChecked(): void { + this._widget.patch({ + slotContent: this.slotContentFromContent?.templateRef, + }); + } + + ngOnChanges(changes: SimpleChanges): void { + patchSimpleChanges(this._widget.patch, changes); + } +} diff --git a/angular/lib/src/public-api.ts b/angular/lib/src/public-api.ts index e3e93800ce..20c2ba07de 100644 --- a/angular/lib/src/public-api.ts +++ b/angular/lib/src/public-api.ts @@ -12,3 +12,4 @@ export * from './lib/modal/modal.service'; export * from './lib/modal/modal.component'; export * from './lib/alert/alert.component'; export * from './lib/accordion/accordion.component'; +export * from './lib/progressbar/progressbar.component'; diff --git a/common/demo.scss b/common/demo.scss index 2d601892d7..c8e49486c7 100644 --- a/common/demo.scss +++ b/common/demo.scss @@ -73,3 +73,90 @@ main { color: #ff1e1e; } } + +.cup { + padding: 0; + height: 210px; + width: 190px; + border: 10px solid #030303; + position: absolute; + transform: translate(-50%, -50%); + top: 50%; + left: 50%; + border-radius: 20px 20px 60px 60px; +} +.cup-fill-parent { + overflow: hidden; + height: 100%; + position: relative; + border-radius: 0px 0px 50px 50px; +} +.cup-fill { + background-position: 0 0, 0 0; + background-repeat: repeat-x; + background-clip: content-box; + animation: coffee 3s infinite linear; + position: absolute; + bottom: 0; + width: 100%; + transition: height 0.6s ease; + overflow-y: hidden; +} +@keyframes coffee { + 100% { + background-position: -200% 0, -100% 0; + } +} +.cup:before { + content: ''; + position: absolute; + height: 80px; + width: 60px; + border: 10px solid #030303; + border-left: none; + right: -70px; + top: 30px; + border-radius: 0 30px 80px 0; +} +.cup:after { + position: absolute; + content: ''; + height: 10px; + width: 260px; + background-color: #030303; + left: -45px; + bottom: -10px; + border-radius: 10px; +} +.bubble { + height: 15px; + width: 15px; + background-color: #fbbe08; + border-radius: 50%; + position: absolute; + animation: bubbles forwards infinite; + opacity: 0.6; +} +@keyframes bubbles { + 100% { + bottom: calc(100% - 20px); + opacity: 0; + } +} +.bubble-1 { + left: 30px; + bottom: 10px; + animation-delay: 0.5s; + animation-duration: 3s; +} +.bubble-2 { + left: 80px; + bottom: 35px; + animation-delay: 1.2s; + animation-duration: 4s; +} +.bubble-3 { + left: 140px; + bottom: 30px; + animation-duration: 4s; +} diff --git a/core/lib/config.ts b/core/lib/config.ts index c363d22e60..fb27ea0765 100644 --- a/core/lib/config.ts +++ b/core/lib/config.ts @@ -6,6 +6,7 @@ import type {PaginationProps} from './pagination'; import type {RatingProps} from './rating'; import type {SelectProps} from './select'; import type {AccordionProps} from './accordion'; +import type {ProgressbarProps} from './progressbar'; import {identity} from './utils'; export type Partial2Levels = Partial<{ @@ -101,4 +102,8 @@ export interface WidgetsConfig { * the accordion widget config */ accordion: AccordionProps; + /** + * the progress bar widget config + */ + progressbar: ProgressbarProps; } diff --git a/core/lib/index.ts b/core/lib/index.ts index 739a6ead08..5e3abd2da8 100644 --- a/core/lib/index.ts +++ b/core/lib/index.ts @@ -9,3 +9,4 @@ export * from './config'; export * from './modal/modal'; export * from './alert'; export * from './accordion'; +export * from './progressbar'; diff --git a/core/lib/progressbar.spec.ts b/core/lib/progressbar.spec.ts new file mode 100644 index 0000000000..e69ab86c6c --- /dev/null +++ b/core/lib/progressbar.spec.ts @@ -0,0 +1,55 @@ +import {beforeEach, describe, expect, test} from 'vitest'; +import {createProgressbar, getProgressbarDefaultConfig} from './progressbar'; +import type {ProgressbarWidget} from './progressbar'; +import type {WidgetState} from './types'; + +describe(`Progressbar`, () => { + let progressbar: ProgressbarWidget; + let state: WidgetState; + + beforeEach(() => { + progressbar = createProgressbar(); + progressbar.state$.subscribe((newState) => { + state = newState; + }); + }); + + test(`should create progressbar with a default state`, () => { + expect(state).toEqual({ + animated: false, + ariaLabel: 'Progressbar', + className: '', + finished: false, + height: '', + maximum: 100, + minimum: 0, + percentage: 0, + showPercentage: false, + slotContent: undefined, + slotDefault: undefined, + started: false, + striped: false, + value: 0, + }); + }); + + test(`should calculate the percentage properly`, () => { + progressbar.patch({minimum: 1, maximum: 5, value: 4}); + expect(state).toContain({percentage: 75, value: 4}); + progressbar.patch({minimum: 10, maximum: 0}); + expect(state).toContain({percentage: 0, minimum: 10, maximum: 10, value: 10}); + }); + + test(`should allow users to reset or end the progress bar from api`, () => { + progressbar.patch({value: 10}); + expect(state).toContain({percentage: 10, started: true, finished: false}); + progressbar.api.end(); + expect(state).toContain({percentage: 100, started: true, finished: true}); + progressbar.api.reset(); + expect(state).toContain({percentage: 0, started: false, finished: false}); + }); + + test(`default config returns an aria label`, () => { + expect(getProgressbarDefaultConfig().ariaLabel).toBe('Progressbar'); + }); +}); diff --git a/core/lib/progressbar.ts b/core/lib/progressbar.ts new file mode 100644 index 0000000000..98dd6dc07c --- /dev/null +++ b/core/lib/progressbar.ts @@ -0,0 +1,191 @@ +import {clamp} from './services/checks'; +import type {ConfigValidator, PropsConfig} from './services'; +import {stateStores, typeBoolean, typeNumber, typeString, writablesForProps} from './services'; +import type {SlotContent, Widget, WidgetSlotContext} from './types'; +import {computed} from '@amadeus-it-group/tansu'; + +export type ProgressbarContext = WidgetSlotContext; + +export interface ProgressbarCommonPropsAndState { + /** + * the minimum value + * @defaultValue 0 + */ + minimum: number; + + /** + * the maximum value + * @defaultValue 100 + */ + maximum: number; + + /** + * the current value + * @defaultValue 0 + */ + value: number; + + /** + * the aria label + */ + ariaLabel: string; + + /** + * class to add to the content + */ + className: string; + + /** + * global template for the Progressbar content + */ + slotContent: SlotContent; + /** + * label of the progress + */ + slotDefault: SlotContent; + /** + * height of the progressbar, can be any valid css height value + */ + height: string; + /** + * if `true`, display the current percentage in the `xx%` format + */ + showPercentage: boolean; + /** + * if `true`, shows a striped progressbar + */ + striped: boolean; + /** + * if `true`, animates a striped progressbar + */ + animated: boolean; +} + +export interface ProgressbarState extends ProgressbarCommonPropsAndState { + /** + * percentage of completion + */ + percentage: number; + /** + * `true` if the value is above its minimum value + */ + started: boolean; + /** + * `true` if the value has reached its maximum value + */ + finished: boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ProgressbarProps extends ProgressbarCommonPropsAndState {} + +export interface ProgressbarApi { + /** + * Reset the progress bar to the minimum value + */ + reset(): void; + + /** + * Set the progress bar to the maximum value. + */ + end(): void; +} + +export type ProgressbarWidget = Widget; + +const defaultConfig: ProgressbarProps = { + minimum: 0, + maximum: 100, + value: 0, + ariaLabel: 'Progressbar', + className: '', + slotContent: undefined, + slotDefault: undefined, + height: '', + showPercentage: false, + striped: false, + animated: false, +}; + +/** + * Retrieve a shallow copy of the default Progressbar config + * @returns the default Progressbar config + */ +export function getProgressbarDefaultConfig(): ProgressbarProps { + return {...defaultConfig}; +} + +const configValidator: ConfigValidator = { + minimum: typeNumber, + maximum: typeNumber, + value: typeNumber, + ariaLabel: typeString, + className: typeString, + height: typeString, + showPercentage: typeBoolean, + striped: typeBoolean, + animated: typeBoolean, +}; + +/** + * Create an ProgressbarWidget with given config props + * @param config - an optional progress bar config + * @returns an ProgressbarWidget + */ +export function createProgressbar(config?: PropsConfig): ProgressbarWidget { + const [ + { + minimum$, + maximum$: _dirtyMaximum$, + // dirty value that needs adjustment: + value$: _dirtyValue$, + ariaLabel$, + className$, + height$, + striped$, + animated$, + showPercentage$, + ...stateProps + }, + patch, + ] = writablesForProps(defaultConfig, config, configValidator); + + const maximum$ = computed(() => Math.max(minimum$(), _dirtyMaximum$())); + const value$ = computed(() => clamp(_dirtyValue$(), maximum$(), minimum$())); + const percentage$ = computed(() => (maximum$() > minimum$() ? clamp(((value$() - minimum$()) * 100) / (maximum$() - minimum$()), 100, 0) : 0)); + const started$ = computed(() => value$() > minimum$()); + const finished$ = computed(() => value$() === maximum$()); + + const reset = () => { + patch({value: minimum$()}); + }; + + const end = () => { + patch({value: maximum$()}); + }; + + return { + ...stateStores({ + minimum$, + maximum$, + value$, + percentage$, + started$, + finished$, + ariaLabel$, + className$, + height$, + striped$, + animated$, + showPercentage$, + ...stateProps, + }), + patch, + api: { + reset, + end, + }, + directives: {}, + actions: {}, + }; +} diff --git a/demo/src/routes/[framework]/components/progressbar/+layout.svelte b/demo/src/routes/[framework]/components/progressbar/+layout.svelte new file mode 100644 index 0000000000..7331fd74af --- /dev/null +++ b/demo/src/routes/[framework]/components/progressbar/+layout.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/demo/src/routes/[framework]/components/progressbar/+page.svelte b/demo/src/routes/[framework]/components/progressbar/+page.svelte new file mode 100644 index 0000000000..a71314603c --- /dev/null +++ b/demo/src/routes/[framework]/components/progressbar/+page.svelte @@ -0,0 +1,6 @@ + diff --git a/demo/src/routes/[framework]/components/progressbar/api/+page.svelte b/demo/src/routes/[framework]/components/progressbar/api/+page.svelte new file mode 100644 index 0000000000..3c73328ba7 --- /dev/null +++ b/demo/src/routes/[framework]/components/progressbar/api/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/demo/src/routes/[framework]/components/progressbar/examples/+page.svelte b/demo/src/routes/[framework]/components/progressbar/examples/+page.svelte new file mode 100644 index 0000000000..402af9f9cd --- /dev/null +++ b/demo/src/routes/[framework]/components/progressbar/examples/+page.svelte @@ -0,0 +1,26 @@ + + + + + + +The label and display of the progressbar can be customized. + + + +

The display can be fully customized while keeping the widget's functionality and accessibility.

+ + + +

+ The progressbar component implements the ARIA progressbar role. +

diff --git a/demo/src/routes/[framework]/components/progressbar/playground/+page.svelte b/demo/src/routes/[framework]/components/progressbar/playground/+page.svelte new file mode 100644 index 0000000000..52a7ed0e7d --- /dev/null +++ b/demo/src/routes/[framework]/components/progressbar/playground/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/demo/src/routes/navigation.ts b/demo/src/routes/navigation.ts index eea6ced4a6..2ab0152672 100644 --- a/demo/src/routes/navigation.ts +++ b/demo/src/routes/navigation.ts @@ -6,6 +6,7 @@ export const menu = [ {label: 'Alert', path: '/components/alert'}, {label: 'Modal', path: '/components/modal'}, {label: 'Pagination', path: '/components/pagination'}, + {label: 'Progressbar', path: '/components/progressbar'}, {label: 'Rating', path: '/components/rating'}, ], } /*, diff --git a/demo/src/service-worker.ts b/demo/src/service-worker.ts deleted file mode 100644 index d267ba53c4..0000000000 --- a/demo/src/service-worker.ts +++ /dev/null @@ -1,65 +0,0 @@ -/// -/// -/// - -declare const self: ServiceWorkerGlobalScope; - -import {build, files, prerendered, version} from '$service-worker'; - -const CACHE = `cache-${version}`; - -const ASSETS = [...build.map((file) => file.replace(/\/index\.html$/, '/')), ...prerendered, ...files]; - -const splitAssets = (assets: string[]) => { - const withHash: string[] = []; - const withoutHash: string[] = []; - const hashRegExp = /[.-]\w{8,}\.\w{2,4}$/; - for (const asset of assets) { - (hashRegExp.test(asset) ? withHash : withoutHash).push(asset); - } - return {withHash, withoutHash}; -}; - -self.addEventListener('install', (event) => { - event.waitUntil( - (async () => { - const cache = await caches.open(CACHE); - const {withHash, withoutHash} = splitAssets(ASSETS); - const missingWithHash: string[] = []; - await Promise.all( - withHash.map(async (url) => { - const response = await caches.match(url); - if (response?.ok) { - cache.put(url, response); - } else { - missingWithHash.push(url); - } - }) - ); - await cache.addAll([...missingWithHash, ...withoutHash]); - await self.skipWaiting(); - })() - ); -}); - -self.addEventListener('activate', (event) => { - event.waitUntil( - (async () => { - await self.clients.claim(); - await Promise.all((await caches.keys()).filter((key) => key !== CACHE).map((key) => caches.delete(key))); - })() - ); -}); - -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - if (event.request.method === 'GET' && ASSETS.includes(url.pathname)) { - event.respondWith( - (async () => { - const cache = await caches.open(CACHE); - const response = await cache.match(url.pathname); - return response!; - })() - ); - } -}); diff --git a/e2e/demo-po/progressbar.po.ts b/e2e/demo-po/progressbar.po.ts new file mode 100644 index 0000000000..4cacbbcf41 --- /dev/null +++ b/e2e/demo-po/progressbar.po.ts @@ -0,0 +1,7 @@ +import {BasePO} from '@agnos-ui/base-po'; + +export class ProgressbarDemoPO extends BasePO { + getComponentSelector(): string { + return '.container'; + } +} diff --git a/e2e/progressbar/progressbar.e2e-spec.ts b/e2e/progressbar/progressbar.e2e-spec.ts new file mode 100644 index 0000000000..e2794d4794 --- /dev/null +++ b/e2e/progressbar/progressbar.e2e-spec.ts @@ -0,0 +1,36 @@ +import {ProgressbarPO} from '@agnos-ui/page-objects'; +import {expect, getTest} from '../fixture'; +import {ProgressbarDemoPO} from 'e2e/demo-po/progressbar.po'; + +const test = getTest(); +test.describe(`Progressbar tests`, () => { + test(`Default progressbar`, async ({page}) => { + const progressbarDemoPO = new ProgressbarDemoPO(page); + const progressbarPO = new ProgressbarPO(page, 3); + + await page.goto('#/progressbar/default'); + await progressbarDemoPO.locatorRoot.waitFor(); + + expect(await progressbarPO.locatorRoot.getAttribute('aria-valuenow')).toBe('80'); + expect(await progressbarPO.locatorInnerBar().getAttribute('class')).toContain('text-bg-warning'); + }); + + test(`Simple customization progressbar`, async ({page}) => { + const progressbarDemoPO = new ProgressbarDemoPO(page); + + await page.goto('#/progressbar/striped'); + await progressbarDemoPO.locatorRoot.waitFor(); + + const customMinMaxBar = new ProgressbarPO(page, 0); + const animatedBar = new ProgressbarPO(page, 1); + const heightBar = new ProgressbarPO(page, 2); + + expect(await customMinMaxBar.locatorRoot.getAttribute('aria-label')).toBe('Step 4 out of 5'); + expect(await customMinMaxBar.locatorRoot.getAttribute('aria-valuemax')).toBe('5'); + expect(await customMinMaxBar.locatorInnerBar().innerText()).toBe('Step 4 out of 5'); + + expect(await animatedBar.locatorInnerBar().getAttribute('class')).toContain('progress-bar-animated'); + + expect(await heightBar.locatorOuterBar()).toHaveCSS('height', '24px'); + }); +}); diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-custom.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-custom.html new file mode 100644 index 0000000000..03fa4c23b2 --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-custom.html @@ -0,0 +1,76 @@ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+

+ + "Need to wake up." + +

+
+
+
+
+ \ No newline at end of file diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-default.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-default.html new file mode 100644 index 0000000000..2f587d9d83 --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-default.html @@ -0,0 +1,104 @@ + +
+
+
+
+
+
+ "20%" +
+
+
+
+
+
+ "40%" +
+
+
+
+
+
+ "60%" +
+
+
+
+
+
+ "80%" +
+
+
+
+
+
+ "100%" +
+
+
+
+
+
+ \ No newline at end of file diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-playground.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-playground.html new file mode 100644 index 0000000000..10bb958cbc --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-playground.html @@ -0,0 +1,26 @@ + +
+
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-striped.html b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-striped.html new file mode 100644 index 0000000000..592aca8d1b --- /dev/null +++ b/e2e/samplesMarkup.e2e-spec.ts-snapshots/progressbar-striped.html @@ -0,0 +1,76 @@ + +
+
+
+ "A progressbar using custom values for minimum and maximum:" +
+
+
+ "Step 4 out of 5" +
+
+
+
+
+ "A striped animated progress bar:" +
+
+
+
+
+
+
+ "Changing the height:" +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/page-objects/lib/index.ts b/page-objects/lib/index.ts index 93726499af..e61df0864f 100644 --- a/page-objects/lib/index.ts +++ b/page-objects/lib/index.ts @@ -4,3 +4,4 @@ export * from './rating.po'; export * from './select.po'; export * from './alert.po'; export * from './accordion.po'; +export * from './progressbar.po'; diff --git a/page-objects/lib/progressbar.po.ts b/page-objects/lib/progressbar.po.ts new file mode 100644 index 0000000000..c394cd0e49 --- /dev/null +++ b/page-objects/lib/progressbar.po.ts @@ -0,0 +1,23 @@ +import {BasePO} from '@agnos-ui/base-po'; +import type {Locator} from '@playwright/test'; + +export const progressbarSelectors = { + rootComponent: '[role="progressbar"]', + outerBar: '.progress', + innerBar: '.progress-bar', +}; + +export class ProgressbarPO extends BasePO { + selectors = structuredClone(progressbarSelectors); + + override getComponentSelector(): string { + return this.selectors.rootComponent; + } + + locatorOuterBar(): Locator { + return this.locatorRoot.locator(this.selectors.outerBar); + } + locatorInnerBar(): Locator { + return this.locatorRoot.locator(this.selectors.innerBar); + } +} diff --git a/react/demo/app/samples/progressbar/Custom.route.tsx b/react/demo/app/samples/progressbar/Custom.route.tsx new file mode 100644 index 0000000000..00c1295bd4 --- /dev/null +++ b/react/demo/app/samples/progressbar/Custom.route.tsx @@ -0,0 +1,69 @@ +import type {AdaptSlotContentProps, ProgressbarContext} from '@agnos-ui/react'; +import {Progressbar} from '@agnos-ui/react'; +import {useEffect, useState} from 'react'; + +const CustomContent = ({state}: AdaptSlotContentProps) => ( +
+
+
+
+ {state.percentage >= 50 ? ( + <> +
+
+
+ + ) : null} +
+
+
+
+); + +const CustomDemo = () => { + const [value, setValue] = useState(0); + const [running, setRunning] = useState(false); + useEffect(() => { + const interval = setInterval(() => { + if (running && value < 100) { + setValue(value + 10); + } + }, 500); + return () => clearInterval(interval); + }, [running, value]); + const start = () => { + setRunning(true); + }; + const toggle = () => { + setRunning(!running); + }; + const reset = () => { + setValue(0); + setRunning(false); + }; + + return ( +
+
+ +
+
+
+ + + +
+

+ {value === 0 ? 'Need to wake up.' : value < 100 ? `Retrieving coffee... ${value}%` : 'Ready to work !'} +

+
+
+ ); +}; +export default CustomDemo; diff --git a/react/demo/app/samples/progressbar/Default.route.tsx b/react/demo/app/samples/progressbar/Default.route.tsx new file mode 100644 index 0000000000..6ae159d11c --- /dev/null +++ b/react/demo/app/samples/progressbar/Default.route.tsx @@ -0,0 +1,15 @@ +import {Progressbar, WidgetsDefaultConfig} from '@agnos-ui/react'; + +const DefaultDemo = () => ( + +
+ + + + + +
+
+); + +export default DefaultDemo; diff --git a/react/demo/app/samples/progressbar/Playground.route.tsx b/react/demo/app/samples/progressbar/Playground.route.tsx new file mode 100644 index 0000000000..8f1af2bd72 --- /dev/null +++ b/react/demo/app/samples/progressbar/Playground.route.tsx @@ -0,0 +1,12 @@ +import {Progressbar, WidgetsDefaultConfig} from '@agnos-ui/react'; +import {useHashChange} from '../../utils'; + +const ProgressbarPlayground = () => { + const {config, props} = useHashChange(); + return ( + + + + ); +}; +export default ProgressbarPlayground; diff --git a/react/demo/app/samples/progressbar/Striped.route.tsx b/react/demo/app/samples/progressbar/Striped.route.tsx new file mode 100644 index 0000000000..064d9a9c60 --- /dev/null +++ b/react/demo/app/samples/progressbar/Striped.route.tsx @@ -0,0 +1,21 @@ +import {Progressbar} from '@agnos-ui/react'; + +const RatingDemo = () => ( + <> +
+ A progressbar using custom values for minimum and maximum: + + Step 4 out of 5 + +
+
+ A striped animated progress bar: + +
+
+ Changing the height: + +
+ +); +export default RatingDemo; diff --git a/react/demo/app/samples/progressbar/coffee-img-1.svg b/react/demo/app/samples/progressbar/coffee-img-1.svg new file mode 100644 index 0000000000..18f7ccf965 --- /dev/null +++ b/react/demo/app/samples/progressbar/coffee-img-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/demo/app/samples/progressbar/coffee-img-2.svg b/react/demo/app/samples/progressbar/coffee-img-2.svg new file mode 100644 index 0000000000..75d22bf607 --- /dev/null +++ b/react/demo/app/samples/progressbar/coffee-img-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/demo/index.css b/react/demo/index.css index 1ecbc98c02..8b16902a1d 100644 --- a/react/demo/index.css +++ b/react/demo/index.css @@ -9,3 +9,7 @@ body { code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +.cup-fill { + background-image: url('/app/samples/progressbar/coffee-img-1.svg'), url('/app/samples/progressbar/coffee-img-2.svg'); +} diff --git a/react/lib/Alert.tsx b/react/lib/Alert.tsx index 8f7aaf2af3..16a2408056 100644 --- a/react/lib/Alert.tsx +++ b/react/lib/Alert.tsx @@ -1,4 +1,4 @@ -import type {AlertContext as AlertCoreContext, WidgetProps} from '@agnos-ui/core'; +import type {AlertContext as AlertCoreContext, WidgetProps, WidgetState} from '@agnos-ui/core'; import {createAlert} from '@agnos-ui/core'; import type {PropsWithChildren} from 'react'; import {forwardRef, useImperativeHandle} from 'react'; @@ -9,7 +9,7 @@ import {useDirective, useWidgetWithConfig} from './utils'; export type AlertContext = AdaptSlotContentProps; export type AlertWidget = AdaptWidgetSlots>; export type AlertProps = WidgetProps; -export type AlertState = WidgetProps; +export type AlertState = WidgetState; export interface AlertReactProps extends AlertProps, Omit, 'className'> {} diff --git a/react/lib/Progressbar.tsx b/react/lib/Progressbar.tsx new file mode 100644 index 0000000000..f413a49610 --- /dev/null +++ b/react/lib/Progressbar.tsx @@ -0,0 +1,52 @@ +import type {ProgressbarContext as ProgressbarCoreContext, WidgetProps, WidgetState} from '@agnos-ui/core'; +import {createProgressbar, toSlotContextWidget} from '@agnos-ui/core'; +import {type PropsWithChildren} from 'react'; +import type {AdaptSlotContentProps, AdaptWidgetSlots} from './Slot'; +import {Slot} from './Slot'; +import {useWidgetWithConfig} from './utils'; + +export type ProgressbarContext = AdaptSlotContentProps; +export type ProgressbarWidget = AdaptWidgetSlots>; +export type ProgressbarProps = WidgetProps; +export type ProgressbarState = WidgetState; + +function DefaultSlotContent(slotContext: ProgressbarContext) { + const classList = ['progress-bar']; + if (slotContext.state.striped) { + classList.push('progress-bar-striped'); + } + if (slotContext.state.animated) { + classList.push('progress-bar-animated'); + } + if (slotContext.state.className) { + classList.push(slotContext.state.className); + } + return ( +
+
+ + {slotContext.state.showPercentage ? <>{slotContext.state.percentage}% : null} +
+
+ ); +} + +const defaultConfig: Partial = { + slotContent: DefaultSlotContent, +}; + +export const Progressbar = (props: PropsWithChildren>) => { + const [state, widget] = useWidgetWithConfig(createProgressbar, props, 'progressbar', {...defaultConfig, slotDefault: props.children}); + const slotContext: ProgressbarContext = {state, widget: toSlotContextWidget(widget)}; + return ( +
+ +
+ ); +}; diff --git a/react/lib/index.ts b/react/lib/index.ts index d50122dcfb..ee94f066d2 100644 --- a/react/lib/index.ts +++ b/react/lib/index.ts @@ -9,3 +9,4 @@ export * from './pagination/PageItem'; export * from './Slot'; export * from './WidgetsDefaultConfig'; export * from './Accordion'; +export * from './Progressbar'; diff --git a/svelte/demo/samples/progressbar/Coffee.svelte b/svelte/demo/samples/progressbar/Coffee.svelte new file mode 100644 index 0000000000..7244a99fcc --- /dev/null +++ b/svelte/demo/samples/progressbar/Coffee.svelte @@ -0,0 +1,28 @@ + + +
+
+
+
+ {#if state.percentage >= 50} +
+
+
+ {/if} +
+
+
+
+ + diff --git a/svelte/demo/samples/progressbar/Custom.route.svelte b/svelte/demo/samples/progressbar/Custom.route.svelte new file mode 100644 index 0000000000..db6afb50fd --- /dev/null +++ b/svelte/demo/samples/progressbar/Custom.route.svelte @@ -0,0 +1,44 @@ + + +
+
+ +
+
+
+ + + +
+

+ + {value === 0 ? 'Need to wake up.' : value < 100 ? `Retrieving coffee... ${value}%` : 'Ready to work !'} + +

+
+
diff --git a/svelte/demo/samples/progressbar/Default.route.svelte b/svelte/demo/samples/progressbar/Default.route.svelte new file mode 100644 index 0000000000..2417a9e1af --- /dev/null +++ b/svelte/demo/samples/progressbar/Default.route.svelte @@ -0,0 +1,15 @@ + + +
+ + + + + +
diff --git a/svelte/demo/samples/progressbar/Playground.route.svelte b/svelte/demo/samples/progressbar/Playground.route.svelte new file mode 100644 index 0000000000..6678857896 --- /dev/null +++ b/svelte/demo/samples/progressbar/Playground.route.svelte @@ -0,0 +1,10 @@ + + +{#key $props$} + +{/key} diff --git a/svelte/demo/samples/progressbar/Striped.route.svelte b/svelte/demo/samples/progressbar/Striped.route.svelte new file mode 100644 index 0000000000..c319d9ede3 --- /dev/null +++ b/svelte/demo/samples/progressbar/Striped.route.svelte @@ -0,0 +1,16 @@ + + +
+ A progressbar using custom values for minimum and maximum: + Step 4 out of 5 +
+
+ A striped animated progress bar: + +
+
+ Changing the height: + +
diff --git a/svelte/demo/samples/progressbar/coffee-img-1.svg b/svelte/demo/samples/progressbar/coffee-img-1.svg new file mode 100644 index 0000000000..18f7ccf965 --- /dev/null +++ b/svelte/demo/samples/progressbar/coffee-img-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svelte/demo/samples/progressbar/coffee-img-2.svg b/svelte/demo/samples/progressbar/coffee-img-2.svg new file mode 100644 index 0000000000..75d22bf607 --- /dev/null +++ b/svelte/demo/samples/progressbar/coffee-img-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svelte/lib/index.ts b/svelte/lib/index.ts index 656a44a3a6..3d2483f25a 100644 --- a/svelte/lib/index.ts +++ b/svelte/lib/index.ts @@ -9,6 +9,7 @@ import ModalDefaultStructure from './modal/ModalDefaultStructure.svelte'; import Alert from './alert/Alert.svelte'; import Accordion from './accordion/Accordion.svelte'; import AccordionItem from './accordion/Item.svelte'; +import Progressbar from './progressbar/Progressbar.svelte'; export {createWidgetsDefaultConfig} from './utils'; export type {WidgetPropsSlots} from './utils'; @@ -20,5 +21,19 @@ export * from './modal/modal'; export * from './modal/modalService'; export * from './alert/alert'; export * from './accordion/accordion'; +export * from './progressbar/progressbar'; -export {Select, Rating, Pagination, PaginationDefaultPages, Slot, Modal, ModalDefaultHeader, ModalDefaultStructure, Alert, Accordion, AccordionItem}; +export { + Select, + Rating, + Pagination, + PaginationDefaultPages, + Slot, + Modal, + ModalDefaultHeader, + ModalDefaultStructure, + Alert, + Accordion, + AccordionItem, + Progressbar, +}; diff --git a/svelte/lib/progressbar/Progressbar.svelte b/svelte/lib/progressbar/Progressbar.svelte new file mode 100644 index 0000000000..8e72d039c4 --- /dev/null +++ b/svelte/lib/progressbar/Progressbar.svelte @@ -0,0 +1,38 @@ + + + + +
+ + + + + + + +
diff --git a/svelte/lib/progressbar/ProgressbarDefaultContent.svelte b/svelte/lib/progressbar/ProgressbarDefaultContent.svelte new file mode 100644 index 0000000000..c5a73a14e9 --- /dev/null +++ b/svelte/lib/progressbar/ProgressbarDefaultContent.svelte @@ -0,0 +1,34 @@ + + +
+
+ + + + + + + + {#if state.showPercentage} + {state.percentage}% + {/if} +
+
diff --git a/svelte/lib/progressbar/progressbar.ts b/svelte/lib/progressbar/progressbar.ts new file mode 100644 index 0000000000..cf515b041c --- /dev/null +++ b/svelte/lib/progressbar/progressbar.ts @@ -0,0 +1,7 @@ +import type {createProgressbar, WidgetProps} from '@agnos-ui/core'; +import type {AdaptWidgetSlots} from '../slot/slot'; +import type {WidgetPropsSlots} from '../utils'; + +export type ProgressbarWidget = AdaptWidgetSlots>; +export type ProgressbarProps = WidgetProps; +export type ProgressbarSlots = WidgetPropsSlots;