From ed78551fd9e8ec1b0c045afb5a8f6ff872812192 Mon Sep 17 00:00:00 2001 From: Ozgur Ersoy Date: Sat, 17 Oct 2026 18:14:31 +0200 Subject: [PATCH 1/2] Elements: Add a ProgressBar component The e-reader's opening modal draws its progress as a track and a fill whose pixel width the app store computes by hand, and the native tag is drawn as an empty box on pixel targets. There was no reusable bar. ProgressBar is a function component built from plain views. The app owns value, min and max. The fill width is the clamped fraction as a percentage, so out-of-range values, empty or inverted ranges and NaN draw as empty or full rather than overflowing, and the bar follows its container when it is resized. The clamping is exported as progressFraction. indeterminate swaps the fill for a CSS keyframe sweep that the track clips; reducedMotion replaces the sweep with a still, dimmed fill, since the engine has no prefers-reduced-motion media feature. The sweep exists only while the indeterminate class is on, so it stops as soon as the bar leaves that mode. The caller's class goes on the root and the parts use fixed progress-bar__ classes. There is no style prop: geatsc has no rule for forwarding a Style object through a function component. The README and .d.ts state the limits: only the value is clamped, the bar exposes no role or value to assistive technology and takes no keyboard input, and the sweep distance is fixed in pixels when the animation starts. The elements package marks *.css as side effects, since this is its first module that imports a stylesheet and a bundler could drop it under sideEffects: false. The demo in geastack/examples depends on @geastack/elements ^0.1.6, which does not export ProgressBar, so it type-checks only against this checkout until an elements release ships the component. Tests: added run-gea-progress-bar-pipeline.sh, which builds examples/apps/progress-bar and taps its buttons. It checks the initial 40%, stepping, values above max and below min, a zero range, resizing and the label. It checks the demo's fixed bars: no props, the default range, a fractional value, an empty label that takes no space, a negative range, an inverted range and NaN. Besides layout pixels it reads the fill's authored width (width_percent, per-mille) exactly, since layout rounds to pixels and clamps a negative width to 0, and it reads progressFraction's NaN result from the demo's label, since a native build parses "NaN%" as 0. It finds the bar by id. Over one sweep cycle it checks that the fill starts left of the track, travels past 70% of it and never paints beside the track. It checks that reduced motion stays still and dimmed, that the sweep stops when indeterminate is cleared, and that the fill sits at the start of the track in both cases. It reads pixels as the display's native pixel type. It passes locally on macOS with Homebrew bash 5, and the full core suite gives the same result as main apart from the new test. Each of these mutations, applied one at a time, makes it fail: no upper clamp, reducedMotion ignored, the label always shown, default min 10, default value 50, no overflow:hidden, no static opacity, the sweep starting or ending at 0%, range === 0 instead of !(range > 0), whole-percent rounding, a NaN-only lower guard, fraction <= 0 instead of !(fraction > 0), NaN mapped to 0.5, and id not forwarded to the root. --- .../test/run-gea-progress-bar-pipeline.sh | 27 ++ .../core/test/test_gea_progress_bar_main.cpp | 408 ++++++++++++++++++ packages/elements/README.md | 8 + packages/elements/components/ProgressBar.css | 49 +++ packages/elements/components/ProgressBar.d.ts | 35 ++ packages/elements/components/ProgressBar.tsx | 72 ++++ packages/elements/components/index.d.ts | 2 + packages/elements/components/index.ts | 1 + packages/elements/package.json | 8 +- 9 files changed, 609 insertions(+), 1 deletion(-) create mode 100755 packages/core/test/run-gea-progress-bar-pipeline.sh create mode 100644 packages/core/test/test_gea_progress_bar_main.cpp create mode 100644 packages/elements/components/ProgressBar.css create mode 100644 packages/elements/components/ProgressBar.d.ts create mode 100644 packages/elements/components/ProgressBar.tsx diff --git a/packages/core/test/run-gea-progress-bar-pipeline.sh b/packages/core/test/run-gea-progress-bar-pipeline.sh new file mode 100755 index 0000000..189c44c --- /dev/null +++ b/packages/core/test/run-gea-progress-bar-pipeline.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +BUILD_DIR="$ROOT/packages/core/test/.build/gea-progress-bar-pipeline" +CXX_BIN="${CXX:-clang++}" + +source "$ROOT/packages/core/test/native-test-common.sh" +gea_require_app_project + +mkdir -p "$BUILD_DIR" + +node "$ROOT/packages/core/scripts/build-gea-vite-geatsc.mjs" \ + --app-dir "$GEA_APP_PROJECT/apps/progress-bar" \ + --entry "index.tsx" \ + --out-dir "$BUILD_DIR" \ + --gea-embedded-compat \ + --gea-ir-backend \ + --pixel-panel-endian 1 \ + --allow-any + +gea_build_native_test \ + "$BUILD_DIR" \ + "$BUILD_DIR/gea-progress-bar-test" \ + "$ROOT/packages/core/test/test_gea_progress_bar_main.cpp" + +"$BUILD_DIR/gea-progress-bar-test" diff --git a/packages/core/test/test_gea_progress_bar_main.cpp b/packages/core/test/test_gea_progress_bar_main.cpp new file mode 100644 index 0000000..f7d389b --- /dev/null +++ b/packages/core/test/test_gea_progress_bar_main.cpp @@ -0,0 +1,408 @@ +#include "native_test_harness.h" + +#include "app.h" +#include "events.h" +#include "ui/document.h" +#include "ui/tree_internal.h" + +#include +#include +#include +#include +#include + +extern void __gea_top_level(); + +namespace { +constexpr int kViewportWidth = 720; +constexpr int kViewportHeight = 1440; +constexpr double kDevicePixelRatio = 2.0; +constexpr const char *kTest = "test_gea_progress_bar_main"; +// The display's own pixel format: 16-bit RGB565 by default, wider or narrower +// on builds that change it. +using Pixel = gea::framework::graphics::pixel::native_t; + +double g_nowMs = 0; + +// core/runtime.cpp starts CSS animation tracks added by later class changes on +// every frame; pumpFrame does not, so do it here the way the device does. +void advance(double ms) +{ + g_nowMs += ms; + gea::embedded::ui::StyleSheet::instance().startCssAnimations(static_cast(g_nowMs)); + gea::embedded::test::pumpFrame(g_nowMs); +} + +int onlyNodeWithClass(const char *className) +{ + const auto nodes = gea::embedded::test::nodesWithClass(className); + if (nodes.size() != 1) { + std::fprintf(stderr, "[%s] expected one .%s node, got %zu\n", kTest, className, nodes.size()); + return -1; + } + return nodes[0]; +} + +bool click(const char *text) +{ + if (!gea::embedded::test::clickFirstText(text, true)) { + std::fprintf(stderr, "[%s] no button labelled %s\n", kTest, text); + return false; + } + advance(16); + return true; +} + +bool hasAncestorWithClass(int node, const char *className) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + for (int id = node; id >= 0; id = tree.node(id).parent) { + if (tree.hasClass(id, className)) return true; + } + return false; +} + +// The visible .partClass nodes inside the bar whose root carries rootClass. +// nodesWithClass skips hidden nodes, so a display:none label is not counted. +std::vector partsOf(const char *rootClass, const char *partClass) +{ + std::vector out; + for (const int id : gea::embedded::test::nodesWithClass(partClass)) { + if (hasAncestorWithClass(id, rootClass)) out.push_back(id); + } + return out; +} + +int partOf(const char *rootClass, const char *partClass) +{ + const auto parts = partsOf(rootClass, partClass); + if (parts.size() != 1) { + std::fprintf(stderr, "[%s] expected one .%s in .%s, got %zu\n", kTest, partClass, rootClass, parts.size()); + return -1; + } + return parts[0]; +} + +// The bars are function components, so a re-render replaces their nodes: look +// them up again for every check instead of holding ids across clicks. +constexpr const char *kDemo = "demo-bar"; +int trackOf(const char *rootClass) { return partOf(rootClass, "progress-bar__track"); } +int fillOf(const char *rootClass) { return partOf(rootClass, "progress-bar__fill"); } + +// Fill width as a share of the track, in per-mille, so the check does not +// depend on the device pixel ratio or the demo's bar width. +int fillPermille(int track, int fill) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + if (track < 0 || fill < 0) return -1; + const int trackWidth = tree.node(track).layout.width; + if (trackWidth <= 0) return -1; + return (tree.node(fill).layout.width * 1000 + trackWidth / 2) / trackWidth; +} + +bool expectFill(const char *rootClass, int expectedPermille, const char *phase) +{ + const int track = trackOf(rootClass); + const int fill = fillOf(rootClass); + if (track < 0 || fill < 0) return false; + const int actual = fillPermille(track, fill); + if (actual < expectedPermille - 5 || actual > expectedPermille + 5) { + auto &tree = gea::embedded::ui::Tree::instance(); + std::fprintf(stderr, + "[%s] %s: fill %d/1000 of track, expected %d (fill=%d track=%d)\n", + kTest, + phase, + actual, + expectedPermille, + tree.node(fill).layout.width, + tree.node(track).layout.width); + return false; + } + return true; +} + +// The fill's authored width, in per-mille of the track, before layout rounds it +// to whole pixels and clamps a negative width to 0. A pixel check cannot tell +// 33.3% from 33% or -20% from 0%; this one can. +bool expectWidthPercent(const char *rootClass, int expectedPermille, const char *phase) +{ + const int fill = fillOf(rootClass); + if (fill < 0) return false; + const int actual = gea::embedded::ui::Tree::instance().node(fill).computedStyle().width_percent; + if (actual != expectedPermille) { + std::fprintf(stderr, "[%s] %s: fill width %d/1000, expected exactly %d\n", kTest, phase, actual, expectedPermille); + return false; + } + return true; +} + +// The fill sits at the start of its track whenever no sweep is running. +bool expectFillAtStart(const char *phase) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const int track = trackOf(kDemo); + const int fill = fillOf(kDemo); + if (track < 0 || fill < 0) return false; + const int offset = tree.node(fill).layout.x - tree.node(track).layout.x; + if (offset != 0) { + std::fprintf(stderr, "[%s] %s: fill offset %d, expected 0\n", kTest, phase, offset); + return false; + } + return true; +} + +// An empty label is hidden and takes no space: the bar is as tall as its track. +bool expectNoLabel(const char *rootClass, const char *phase) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const int root = onlyNodeWithClass(rootClass); + const int track = trackOf(rootClass); + if (root < 0 || track < 0) return false; + const std::size_t labels = partsOf(rootClass, "progress-bar__label").size(); + if (labels != 0 || tree.node(root).layout.height != tree.node(track).layout.height) { + std::fprintf(stderr, + "[%s] %s: expected no visible label, got %zu (bar %d tall, track %d)\n", + kTest, + phase, + labels, + tree.node(root).layout.height, + tree.node(track).layout.height); + return false; + } + return true; +} + +bool expectText(const char *needle, const char *phase) +{ + return gea::embedded::test::expectContains(gea::embedded::test::rootTextContent(), needle, phase, kTest); +} + +std::uint32_t rowChecksum(int y, int fromX, int toX) +{ + std::uint32_t sum = 0; + for (int x = fromX; x < toX; ++x) sum = sum * 31u + gea::embedded::test::displayPixelAt(x, y); + return sum; +} + +int trackMidY(int track) +{ + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + return layout.y + layout.height / 2; +} + +// A checksum of the painted pixels along the middle row of the demo track. It +// moves when the fill is painted somewhere else, whatever the animation drives. +std::uint32_t trackRowChecksum() +{ + const int track = trackOf(kDemo); + if (track < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + return rowChecksum(trackMidY(track), layout.x, layout.x + layout.width); +} + +// The same row left and right of the demo track. The fill must never paint +// there, or the track does not clip it. +std::uint32_t besideTrackChecksum() +{ + const int track = trackOf(kDemo); + if (track < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + const int y = trackMidY(track); + return rowChecksum(y, 0, layout.x) * 31u + rowChecksum(y, layout.x + layout.width, kViewportWidth); +} + +Pixel fillCenterPixel() +{ + const int fill = fillOf(kDemo); + if (fill < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(fill).layout; + return gea::embedded::test::displayPixelAt(layout.x + layout.width / 2, layout.y + layout.height / 2); +} + +int distinctChecksums(int frames, double stepMs) +{ + std::vector seen; + for (int i = 0; i < frames; ++i) { + advance(stepMs); + const std::uint32_t sum = trackRowChecksum(); + bool known = false; + for (const auto value : seen) known = known || value == sum; + if (!known) seen.push_back(sum); + } + return static_cast(seen.size()); +} + +// Steps through one sweep cycle and checks the fill starts left of the track, +// travels past 70% of its width, and is clipped to the track throughout. +bool expectSweep(double stepMs, int frames) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const std::uint32_t beside = besideTrackChecksum(); + int minOffset = 1 << 30; + int maxOffset = -(1 << 30); + std::vector seen; + for (int i = 0; i < frames; ++i) { + advance(stepMs); + const int track = trackOf(kDemo); + const int fill = fillOf(kDemo); + if (track < 0 || fill < 0) return false; + const int offset = tree.node(fill).layout.x - tree.node(track).layout.x; + if (offset < minOffset) minOffset = offset; + if (offset > maxOffset) maxOffset = offset; + if (besideTrackChecksum() != beside) { + std::fprintf(stderr, "[%s] busy: the fill painted outside the track at frame %d (offset %d)\n", kTest, i, offset); + return false; + } + const std::uint32_t sum = trackRowChecksum(); + bool known = false; + for (const auto value : seen) known = known || value == sum; + if (!known) seen.push_back(sum); + } + const int trackWidth = tree.node(trackOf(kDemo)).layout.width; + if (minOffset >= -trackWidth / 10 || maxOffset <= trackWidth * 7 / 10 || seen.size() < 3) { + std::fprintf(stderr, + "[%s] busy: sweep offsets %d..%d on a %d track, %zu distinct frames\n", + kTest, + minOffset, + maxOffset, + trackWidth, + seen.size()); + return false; + } + return true; +} +} // namespace + +int main() +{ + using namespace gea::embedded::test; + auto &tree = gea::embedded::ui::Tree::instance(); + + resetNativeHost(); + setNativeDisplaySize(kViewportWidth, kViewportHeight); + gea::framework::app::Application::init(kViewportWidth, kViewportHeight, kDevicePixelRatio); + __gea_top_level(); + refresh(); + advance(16); + + if (trackOf(kDemo) < 0 || fillOf(kDemo) < 0) { + dumpTree(kTest); + return 1; + } + const int wideTrack = tree.node(trackOf(kDemo)).layout.width; + bool ok = wideTrack > 0; + if (!ok) std::fprintf(stderr, "[%s] track has no width\n", kTest); + + // The fixed bars: no props draws empty with no label, min/max default to + // 0-100, the width keeps a tenth of a percent, a negative range works, and + // an inverted range or a NaN value draws empty. + ok = ok && expectFill("demo-case--defaults", 0, "no props"); + ok = ok && expectNoLabel("demo-case--defaults", "no props label"); + ok = ok && expectFill("demo-case--default-range", 250, "default 0-100 range"); + ok = ok && expectWidthPercent("demo-case--default-range", 250, "default 0-100 range width"); + ok = ok && expectFill("demo-case--fraction", 333, "fractional value"); + ok = ok && expectWidthPercent("demo-case--fraction", 333, "fractional value width"); + ok = ok && expectNoLabel("demo-case--fraction", "empty label"); + ok = ok && expectFill("demo-case--negative", 500, "negative range"); + ok = ok && expectWidthPercent("demo-case--inverted", 0, "inverted range"); + ok = ok && expectWidthPercent("demo-case--nan", 0, "NaN value"); + // A native build parses "NaN%" as 0, so the width alone cannot show that + // progressFraction catches NaN; the demo prints its result in the label. + ok = ok && expectText("NaN gives [0]", "NaN fraction"); + + // The app's id lands on the bar's root. + if (ok) { + const int root = onlyNodeWithClass(kDemo); + const int byId = gea::embedded::ui::Document::instance().getElementById("demo-bar").id(); + if (root < 0 || byId != root) { + std::fprintf(stderr, "[%s] id: #demo-bar is node %d, the .demo-bar root is %d\n", kTest, byId, root); + ok = false; + } + } + + ok = ok && expectFill(kDemo, 400, "initial 40/100"); + ok = ok && expectText("value 40 of 0-100", "initial label"); + const Pixel solidFill = fillCenterPixel(); + Pixel emptyTrack = 0; + if (ok) { + const auto &layout = tree.node(trackOf(kDemo)).layout; + emptyTrack = displayPixelAt(layout.x + layout.width - 4, trackMidY(trackOf(kDemo))); + if (solidFill == emptyTrack) { + std::fprintf(stderr, "[%s] initial: fill and track paint the same colour\n", kTest); + ok = false; + } + } + + ok = ok && click("Plus") && expectFill(kDemo, 650, "after Plus"); + ok = ok && click("Over") && expectFill(kDemo, 1000, "value above max"); + ok = ok && expectWidthPercent(kDemo, 1000, "value above max width"); + ok = ok && click("Under") && expectFill(kDemo, 0, "value below min"); + ok = ok && expectWidthPercent(kDemo, 0, "value below min width"); + + ok = ok && click("Reset") && click("Zero range") && expectFill(kDemo, 0, "zero range"); + ok = ok && expectText("value 40 of 50-50 (zero range)", "zero range label"); + + ok = ok && click("Reset") && click("Resize"); + if (ok) { + const int narrow = trackOf(kDemo); + if (narrow < 0 || tree.node(narrow).layout.width * 2 != wideTrack) { + std::fprintf(stderr, "[%s] resize: track %d, expected half of %d\n", kTest, narrow < 0 ? -1 : tree.node(narrow).layout.width, wideTrack); + ok = false; + } + } + ok = ok && expectFill(kDemo, 400, "after resize"); + + ok = ok && click("Reset") && click("Busy"); + ok = ok && expectText("Working", "busy label"); + if (ok && !tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate")) { + std::fprintf(stderr, "[%s] busy: fill lacks the indeterminate class\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 300, "busy segment"); + ok = ok && expectSweep(100, 12); + + ok = ok && click("Still"); + if (ok && (tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate") || !tree.hasClass(fillOf(kDemo), "progress-bar__fill--static"))) { + std::fprintf(stderr, "[%s] reduced motion: expected only the static class on the fill\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 1000, "reduced motion fill"); + ok = ok && expectFillAtStart("reduced motion offset"); + if (ok) { + advance(16); + const Pixel dimmed = fillCenterPixel(); + if (dimmed == solidFill || dimmed == emptyTrack) { + std::fprintf(stderr, "[%s] reduced motion: expected a dimmed fill, got %x (solid %x, track %x)\n", kTest, static_cast(dimmed), static_cast(solidFill), static_cast(emptyTrack)); + ok = false; + } + } + if (ok) { + const int distinct = distinctChecksums(12, 100); + if (distinct != 1) { + std::fprintf(stderr, "[%s] reduced motion: expected a still bar, %d distinct frames\n", kTest, distinct); + ok = false; + } + } + + ok = ok && click("Still") && click("Busy"); + if (ok && tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate")) { + std::fprintf(stderr, "[%s] idle: fill kept the indeterminate class\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 400, "back to determinate"); + ok = ok && expectFillAtStart("idle offset"); + if (ok) { + advance(16); + const int distinct = distinctChecksums(12, 100); + if (distinct != 1) { + std::fprintf(stderr, "[%s] idle: expected the sweep to stop, %d distinct frames\n", kTest, distinct); + ok = false; + } + } + + if (!ok) { + dumpTree(kTest); + return 1; + } + return 0; +} diff --git a/packages/elements/README.md b/packages/elements/README.md index 14ff8f1..9a9f486 100644 --- a/packages/elements/README.md +++ b/packages/elements/README.md @@ -11,6 +11,14 @@ npm install @geastack/elements an item template. - `CameraView`: a live camera preview bound to the host camera service. The `CameraController` types are re-exported from `@geastack/core`. +- `ProgressBar`: a determinate or indeterminate progress track built from plain + views, with a `value` clamped to `min`..`max` (0-100 by default), an optional + label and a `reducedMotion` switch that stills the indeterminate sweep. The + clamping is exported as `progressFraction(value, min, max)`. The sweep + distance is fixed in pixels when the animation starts, so a bar resized + while busy keeps the old distance until `indeterminate` is toggled. The bar + exposes no role or value to assistive technology and takes no keyboard + input, so put the value in `label` when the user needs to read it. ```tsx import { VirtualList } from '@geastack/elements' diff --git a/packages/elements/components/ProgressBar.css b/packages/elements/components/ProgressBar.css new file mode 100644 index 0000000..69cc43b --- /dev/null +++ b/packages/elements/components/ProgressBar.css @@ -0,0 +1,49 @@ +.progress-bar__track { + position: relative; + height: 8px; + overflow: hidden; + background-color: #e2ddd0; +} + +.progress-bar__fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background-color: #171713; +} + +.progress-bar__fill--indeterminate { + animation: progress-bar-sweep 1200ms linear infinite; +} + +.progress-bar__fill--static { + opacity: 0.4; +} + +.progress-bar__label { + margin-top: 8px; + color: #171713; + font-size: 14px; +} + +@keyframes progress-bar-sweep { + from { + left: -30%; + } + to { + left: 100%; + } +} + +@media (monochrome) { + .progress-bar__track { + background-color: #d9d9d9; + } + .progress-bar__fill { + background-color: #000000; + } + .progress-bar__label { + color: #000000; + } +} diff --git a/packages/elements/components/ProgressBar.d.ts b/packages/elements/components/ProgressBar.d.ts new file mode 100644 index 0000000..e388077 --- /dev/null +++ b/packages/elements/components/ProgressBar.d.ts @@ -0,0 +1,35 @@ +import type { ClassValue } from '@geastack/core' + +export interface ProgressBarProps { + /** Current value, clamped to [min, max]. Defaults to 0. */ + value?: number + /** Defaults to 0. */ + min?: number + /** Defaults to 100. When max <= min the bar shows empty. */ + max?: number + /** + * Show an animated sweep instead of a value. The sweep distance is fixed in + * pixels when the animation starts, so a bar that changes width while busy + * keeps the old distance until `indeterminate` is toggled. + */ + indeterminate?: boolean + /** With `indeterminate`, show a still, dimmed full-width fill instead of the sweep. */ + reducedMotion?: boolean + /** Text shown under the track. Hidden when empty. */ + label?: string + id?: string + /** Applied to the root; size the bar here. Restyle parts with `.your-class .progress-bar__track`, `__fill`, `__label`. */ + class?: ClassValue +} + +/** The clamped position of `value` in [min, max], from 0 to 1. Returns 0 for an empty or inverted range and for NaN. */ +export declare function progressFraction(value: number, min: number, max: number): number + +/** + * A progress track composed from plain views. + * + * Accessibility: the bar exposes no role or value to assistive technology on + * any target, and it takes no keyboard input. Put the value in `label` when + * the user needs to read it. + */ +export default function ProgressBar(props: ProgressBarProps): any diff --git a/packages/elements/components/ProgressBar.tsx b/packages/elements/components/ProgressBar.tsx new file mode 100644 index 0000000..e78ab8e --- /dev/null +++ b/packages/elements/components/ProgressBar.tsx @@ -0,0 +1,72 @@ +import type { ClassValue } from '@geastack/core' +import './ProgressBar.css' + +// A horizontal progress track composed from plain views, so it renders the same +// on pixel targets, where the native tag is drawn as an empty box, +// and on desktop renderers. The app owns `value`; the bar only draws it. +// The caller's `class` lands on the root; the bar's own parts use fixed +// `progress-bar__*` classes so callers restyle them with descendant selectors +// instead of the component merging two ClassValues. There is no `style` +// prop: geatsc has no rule for forwarding a Style object through a function +// component, so size and place the bar through `class`. + +export interface ProgressBarProps { + value?: number + min?: number + max?: number + indeterminate?: boolean + reducedMotion?: boolean + label?: string + id?: string + class?: ClassValue +} + +// The negated comparisons also send NaN to 0, so a bad value never reaches the +// layout as a NaN width. +export function progressFraction(value: number, min: number, max: number): number { + const range = max - min + if (!(range > 0)) return 0 + const fraction = (value - min) / range + if (!(fraction > 0)) return 0 + if (fraction > 1) return 1 + return fraction +} + +function fillWidth(value: number, min: number, max: number, indeterminate: boolean, reducedMotion: boolean): `${number}%` { + if (indeterminate) return reducedMotion ? '100%' : '30%' + return `${Math.round(progressFraction(value, min, max) * 1000) / 10}%` +} + +export default function ProgressBar({ + value, + min, + max, + indeterminate, + reducedMotion, + label, + id, + class: cls +}: ProgressBarProps) { + const lo = min === undefined ? 0 : min + const hi = max === undefined ? 100 : max + const busy = indeterminate === true + const still = reducedMotion === true + const text = label === undefined ? '' : label + return ( +
+
+
+
+ 0 ? 'block' : 'none' }}> + {text} + +
+ ) +} diff --git a/packages/elements/components/index.d.ts b/packages/elements/components/index.d.ts index 91abb48..aa93705 100644 --- a/packages/elements/components/index.d.ts +++ b/packages/elements/components/index.d.ts @@ -3,3 +3,5 @@ export type { CameraViewProps } from './CameraView' export type { CameraFacing, CameraFlashMode, CameraDevice, CameraOpenOptions, CameraCaptureOptions, CameraPhoto, CameraController } from '@geastack/core' export { default as VirtualList } from './VirtualList' export type { VirtualListProps, VirtualListItemTemplate } from './VirtualList' +export { default as ProgressBar, progressFraction } from './ProgressBar' +export type { ProgressBarProps } from './ProgressBar' diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts index 992a63c..239d4e1 100644 --- a/packages/elements/components/index.ts +++ b/packages/elements/components/index.ts @@ -1,2 +1,3 @@ export { CameraView } from './CameraView' export { default as VirtualList } from './VirtualList' +export { default as ProgressBar, progressFraction } from './ProgressBar' diff --git a/packages/elements/package.json b/packages/elements/package.json index a28afec..6b8af1e 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -26,6 +26,10 @@ "types": "./components/CameraView.d.ts", "default": "./components/CameraView.tsx" }, + "./components/ProgressBar": { + "types": "./components/ProgressBar.d.ts", + "default": "./components/ProgressBar.tsx" + }, "./components/VirtualList": { "types": "./components/VirtualList.d.ts", "default": "./components/VirtualList.tsx" @@ -39,7 +43,9 @@ "publishConfig": { "access": "public" }, - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "dependencies": { "@geastack/core": "^0.1.40" } From d90a9e09f1e49084e839cfe05688b70647630ea7 Mon Sep 17 00:00:00 2001 From: Ozgur Ersoy Date: Sun, 18 Oct 2026 15:02:20 +0200 Subject: [PATCH 2/2] Elements: Export ProgressBarProps from the components index A native build resolves value imports of @geastack/elements to components/index.ts, not index.d.ts. index.ts exported ProgressBar and progressFraction but not the props type, so an app that wrote `import { ProgressBar, type ProgressBarProps }` from '@geastack/elements' type-checked but failed to compile natively with "has no exported member named 'ProgressBarProps'". index.ts now re-exports the type as index.d.ts does. Tests: a scratch app importing ProgressBar and ProgressBarProps this way, built with run-gea-native-app-pipeline.sh: fails with that error before the change, builds and mounts after it. run-gea-progress-bar-pipeline.sh still passes. --- packages/elements/components/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts index 239d4e1..d028f0d 100644 --- a/packages/elements/components/index.ts +++ b/packages/elements/components/index.ts @@ -1,3 +1,4 @@ export { CameraView } from './CameraView' export { default as VirtualList } from './VirtualList' export { default as ProgressBar, progressFraction } from './ProgressBar' +export type { ProgressBarProps } from './ProgressBar'