diff --git a/packages/core/test/fixtures/gea-elements-inline-alert/Alerts.tsx b/packages/core/test/fixtures/gea-elements-inline-alert/Alerts.tsx
new file mode 100644
index 0000000..19e24cd
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-inline-alert/Alerts.tsx
@@ -0,0 +1,108 @@
+import { Store } from '@geastack/core'
+import { InlineAlert, type InlineAlertVariant } from '@geastack/elements'
+
+// One alert per behavior the native test checks. The app owns visibility:
+// only the error alert is removed when it reports onDismiss.
+class AlertsStore extends Store {
+ installs = 0
+ dismissCalls = 0
+ errorOpen = true
+ plainVariant: InlineAlertVariant = 'error'
+}
+
+const alerts = new AlertsStore()
+
+export function Alerts() {
+ return (
+
+ {
+ alerts.installs = alerts.installs + 1
+ }}
+ />
+ {
+ alerts.dismissCalls = alerts.dismissCalls + 1
+ }}
+ />
+
+ {alerts.errorOpen ? (
+ {
+ alerts.errorOpen = false
+ }}
+ />
+ ) : null}
+
+
+ {
+ alerts.installs = alerts.installs + 100
+ }}
+ dismissible={true}
+ dismissLabel="Close"
+ />
+ {
+ alerts.installs = alerts.installs + 10
+ }}
+ dismissible={true}
+ dismissLabel="Later"
+ />
+ {
+ alerts.plainVariant = 'warning'
+ }}
+ >
+ Downgrade
+
+ {'installs=' + alerts.installs + ' dismissCalls=' + alerts.dismissCalls}
+
+ )
+}
diff --git a/packages/core/test/fixtures/gea-elements-inline-alert/App.tsx b/packages/core/test/fixtures/gea-elements-inline-alert/App.tsx
new file mode 100644
index 0000000..df3c23d
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-inline-alert/App.tsx
@@ -0,0 +1,12 @@
+import { Component } from '@geastack/core'
+import { Alerts } from './Alerts'
+
+export class App extends Component {
+ template() {
+ return (
+
+ )
+ }
+}
diff --git a/packages/core/test/fixtures/gea-elements-inline-alert/index.tsx b/packages/core/test/fixtures/gea-elements-inline-alert/index.tsx
new file mode 100644
index 0000000..b83d2c3
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-inline-alert/index.tsx
@@ -0,0 +1,5 @@
+import { mount } from '@geastack/core'
+import { App } from './App'
+import './styles.css'
+
+mount(App)
diff --git a/packages/core/test/fixtures/gea-elements-inline-alert/styles.css b/packages/core/test/fixtures/gea-elements-inline-alert/styles.css
new file mode 100644
index 0000000..7fdb7f0
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-inline-alert/styles.css
@@ -0,0 +1,19 @@
+.app {
+ width: 100vw;
+ height: 100vh;
+}
+
+.alerts {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px;
+}
+
+.narrow {
+ width: 200px;
+}
+
+.status {
+ font-size: 14px;
+}
diff --git a/packages/core/test/run-gea-elements-inline-alert-pipeline.sh b/packages/core/test/run-gea-elements-inline-alert-pipeline.sh
new file mode 100755
index 0000000..1a131a2
--- /dev/null
+++ b/packages/core/test/run-gea-elements-inline-alert-pipeline.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+exec "$ROOT/packages/core/test/run-gea-native-app-pipeline.sh" \
+ "gea-elements-inline-alert-pipeline" \
+ "$ROOT/packages/core/test/fixtures/gea-elements-inline-alert" \
+ "index.tsx" \
+ "packages/core/test/test_gea_elements_inline_alert_main.cpp"
diff --git a/packages/core/test/test_gea_elements_inline_alert_main.cpp b/packages/core/test/test_gea_elements_inline_alert_main.cpp
new file mode 100644
index 0000000..5fb7eac
--- /dev/null
+++ b/packages/core/test/test_gea_elements_inline_alert_main.cpp
@@ -0,0 +1,402 @@
+#include "native_test_harness.h"
+
+#include "app.h"
+#include "ui/document.h"
+#include "ui/tree_internal.h"
+
+#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_elements_inline_alert_main";
+using Pixel = gea::framework::graphics::pixel::native_t;
+
+const char *const kVariants[] = {"info", "success", "warning", "error"};
+
+double g_nowMs = 16;
+
+void advance()
+{
+ g_nowMs += 16;
+ gea::embedded::test::pumpFrame(g_nowMs);
+}
+
+bool fail(const char *phase, const std::string &message)
+{
+ std::fprintf(stderr, "[%s] %s: %s\n", kTest, phase, message.c_str());
+ return false;
+}
+
+// The alerts are function components, so a re-render replaces their nodes:
+// look every node up again for each check instead of holding ids.
+int rootOf(const char *id) { return gea::embedded::ui::Document::instance().getElementById(id).id(); }
+
+bool isInside(int node, int ancestor)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ for (int id = node; id >= 0; id = tree.node(id).parent) {
+ if (id == ancestor) return true;
+ }
+ return false;
+}
+
+// The visible .partClass nodes inside the alert whose root has this id.
+// nodesWithClass skips hidden nodes.
+std::vector partsOf(const char *id, const char *partClass)
+{
+ std::vector out;
+ const int root = rootOf(id);
+ if (root < 0) return out;
+ for (const int node : gea::embedded::test::nodesWithClass(partClass)) {
+ if (isInside(node, root)) out.push_back(node);
+ }
+ return out;
+}
+
+int partOf(const char *id, const char *partClass)
+{
+ const auto parts = partsOf(id, partClass);
+ if (parts.size() != 1) {
+ std::fprintf(stderr, "[%s] expected one .%s in #%s, got %zu\n", kTest, partClass, id, parts.size());
+ return -1;
+ }
+ return parts[0];
+}
+
+std::string partText(const char *id, const char *partClass)
+{
+ const int node = partOf(id, partClass);
+ return node < 0 ? std::string("") : gea::embedded::test::textContent(node);
+}
+
+std::string statusText()
+{
+ const auto nodes = gea::embedded::test::nodesWithClass("status");
+ return nodes.size() == 1 ? gea::embedded::test::textContent(nodes[0]) : std::string("");
+}
+
+bool expectStatus(const char *phase, const char *expected)
+{
+ const std::string actual = statusText();
+ if (actual != expected) return fail(phase, "status \"" + actual + "\", expected \"" + expected + "\"");
+ return true;
+}
+
+// The root carries the caller's id and class; the box inside it carries the
+// alert's own classes, and exactly one variant class.
+bool expectVariant(const char *id, const char *callerClass, const char *variant)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const int root = rootOf(id);
+ if (root < 0) return fail(id, "no element with this id");
+ if (!tree.hasClass(root, callerClass)) return fail(id, "root \"" + tree.className(root) + "\" lacks the caller's class " + callerClass);
+ if (tree.hasClass(root, "inline-alert")) return fail(id, "the caller's root also carries .inline-alert");
+ const int box = partOf(id, "inline-alert");
+ if (box < 0) return false;
+ for (const char *other : kVariants) {
+ const std::string token = std::string("inline-alert--") + other;
+ const bool expected = std::string(other) == variant;
+ if (tree.hasClass(box, token) != expected)
+ return fail(id, "box \"" + tree.className(box) + "\" " + (expected ? "lacks " : "has ") + token);
+ }
+ return true;
+}
+
+bool expectPartText(const char *id, const char *partClass, const char *expected)
+{
+ const std::string actual = partText(id, partClass);
+ if (actual != expected) return fail(id, std::string(".") + partClass + " is \"" + actual + "\", expected \"" + expected + "\"");
+ return true;
+}
+
+bool expectNoPart(const char *id, const char *partClass)
+{
+ const auto parts = partsOf(id, partClass);
+ if (!parts.empty()) return fail(id, std::string("has a .") + partClass + " but should not render one");
+ return true;
+}
+
+// The box's own fill, sampled inside its padding next to the right edge, and
+// its left border stripe. Returns false when the alert has no box.
+bool boxColors(const char *id, Pixel &fill, Pixel &stripe)
+{
+ const int box = partOf(id, "inline-alert");
+ if (box < 0) return false;
+ const auto &layout = gea::embedded::ui::Tree::instance().node(box).layout;
+ fill = gea::embedded::test::displayPixelAt(layout.x + layout.width - 6, layout.y + 6);
+ stripe = gea::embedded::test::displayPixelAt(layout.x + 3, layout.y + layout.height / 2);
+ return true;
+}
+
+// Each variant is drawn in its own colors, and its stripe stands out from its
+// fill. The severity label carries the meaning; this checks the colors follow
+// the variant rather than one shared style.
+bool expectVariantColors()
+{
+ const char *const ids[] = {"alert-info", "alert-success", "alert-warning", "alert-error"};
+ std::set fills, stripes;
+ bool ok = true;
+ for (const char *id : ids) {
+ Pixel fill = 0, stripe = 0;
+ if (!boxColors(id, fill, stripe)) return false;
+ if (fill == stripe) ok = fail(id, "stripe and fill are the same color");
+ fills.insert(static_cast(fill));
+ stripes.insert(static_cast(stripe));
+ }
+ if (fills.size() != 4) ok = fail("colors", "the four variants share fill colors (" + std::to_string(fills.size()) + " distinct)");
+ if (stripes.size() != 4) ok = fail("colors", "the four variants share stripe colors (" + std::to_string(stripes.size()) + " distinct)");
+ Pixel plain = 0, error = 0, unused = 0;
+ if (!boxColors("alert-plain", plain, unused) || !boxColors("alert-error", error, unused)) return false;
+ if (plain != error) ok = fail("colors", "two error alerts are filled differently");
+ return ok;
+}
+
+// The root's role, which the browser target reads; the native tree keeps the
+// attribute but nothing reads it.
+bool expectRole(const char *id, const char *expected)
+{
+ const int root = rootOf(id);
+ if (root < 0) return fail(id, "no element with this id");
+ const char *role = gea::embedded::ui::Tree::instance().getAttribute(root, "role");
+ if (!role || std::string(role) != expected) return fail(id, std::string("role is \"") + (role ? role : "") + "\", expected \"" + expected + "\"");
+ return true;
+}
+
+// The parts read top to bottom: severity, title, message, then the buttons.
+bool expectPartOrder(const char *id)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const char *const parts[] = {"inline-alert__severity", "inline-alert__title", "inline-alert__message", "inline-alert__actions"};
+ int previousBottom = -1;
+ for (const char *part : parts) {
+ const int node = partOf(id, part);
+ if (node < 0) return false;
+ const auto &layout = tree.node(node).layout;
+ if (layout.y < previousBottom) return fail(id, std::string(".") + part + " is not below the part before it");
+ previousBottom = layout.y + layout.height;
+ }
+ return true;
+}
+
+// A long message wraps inside a narrow alert instead of running past it.
+bool expectWrapped(const char *id)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const int root = rootOf(id), message = partOf(id, "inline-alert__message");
+ if (root < 0 || message < 0) return false;
+ const auto &box = tree.node(root).layout;
+ const auto &text = tree.node(message).layout;
+ const int lineHeight = static_cast(20 * kDevicePixelRatio);
+ bool ok = true;
+ if (box.width != static_cast(200 * kDevicePixelRatio)) ok = fail(id, "root is " + std::to_string(box.width) + " px wide, expected the caller's 200 px");
+ if (text.height < 3 * lineHeight) ok = fail(id, "message is " + std::to_string(text.height) + " px tall, expected at least three wrapped lines");
+ if (text.x < box.x || text.x + text.width > box.x + box.width) ok = fail(id, "message runs outside the alert");
+ if (box.y + box.height < text.y + text.height) ok = fail(id, "the alert does not grow with the wrapped message");
+ return ok;
+}
+
+// A long title wraps inside a narrow alert, which grows with it.
+bool expectTitleWrapped(const char *id)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const int root = rootOf(id), title = partOf(id, "inline-alert__title"), severity = partOf(id, "inline-alert__severity");
+ if (root < 0 || title < 0 || severity < 0) return false;
+ const auto &box = tree.node(root).layout;
+ const auto &text = tree.node(title).layout;
+ const int oneLine = tree.node(severity).layout.height;
+ bool ok = true;
+ if (box.width != static_cast(200 * kDevicePixelRatio)) ok = fail(id, "root is " + std::to_string(box.width) + " px wide, expected the caller's 200 px");
+ if (text.height < 2 * oneLine) ok = fail(id, "title is " + std::to_string(text.height) + " px tall, expected it to wrap");
+ if (text.x < box.x || text.x + text.width > box.x + box.width) ok = fail(id, "title runs outside the alert");
+ if (box.y + box.height < text.y + text.height) ok = fail(id, "the alert does not grow with the wrapped title");
+ return ok;
+}
+
+// A button stays inside the actions row, exactly as large as its one-line
+// label plus padding and border, and, when endsLine is set, ends its line at
+// the row's end.
+bool expectButtonFits(const char *id, const char *buttonClass, bool endsLine = true)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const int row = partOf(id, "inline-alert__actions"), button = partOf(id, buttonClass);
+ if (row < 0 || button < 0) return false;
+ const int label = tree.node(button).first_child;
+ if (label < 0) return fail(id, std::string(".") + buttonClass + " has no label");
+ const auto &r = tree.node(row).layout;
+ const auto &b = tree.node(button).layout;
+ const auto &t = tree.node(label).layout;
+ const std::string name = std::string(".") + buttonClass;
+ // 14 px text; a second line would make the label at least twice as tall.
+ const int fontPx = static_cast(14 * kDevicePixelRatio);
+ // fit-content: 12 px padding and a 1 px border on each side, 6 px
+ // padding above and below.
+ const int chromeX = static_cast(2 * (12 + 1) * kDevicePixelRatio);
+ const int chromeY = static_cast(2 * (6 + 1) * kDevicePixelRatio);
+ bool ok = true;
+ if (t.height <= 0 || t.height >= 2 * fontPx) ok = fail(id, name + " label is " + std::to_string(t.height) + " px tall; it should fit on one line");
+ if (b.width != t.width + chromeX) ok = fail(id, name + " is " + std::to_string(b.width) + " px wide; it should size to its label");
+ if (b.height != t.height + chromeY) ok = fail(id, name + " is " + std::to_string(b.height) + " px tall; it should size to its label");
+ if (b.x < r.x || b.x + b.width > r.x + r.width) ok = fail(id, name + " runs outside the actions row");
+ if (b.y < r.y || b.y + b.height > r.y + r.height) ok = fail(id, name + " runs outside the actions row vertically");
+ if (endsLine && b.x + b.width != r.x + r.width) ok = fail(id, name + " does not end at the row's end");
+ return ok;
+}
+
+// With both buttons, the action comes first. When they fit, they share one
+// line and the dismiss button ends it; when they do not, the dismiss button
+// wraps below and each ends its own line.
+bool expectBothButtons(const char *id, bool oneLine)
+{
+ auto &tree = gea::embedded::ui::Tree::instance();
+ const int action = partOf(id, "inline-alert__action"), dismiss = partOf(id, "inline-alert__dismiss");
+ if (action < 0 || dismiss < 0) return false;
+ const auto &a = tree.node(action).layout;
+ const auto &d = tree.node(dismiss).layout;
+ bool ok = expectButtonFits(id, "inline-alert__dismiss");
+ ok = expectButtonFits(id, "inline-alert__action", !oneLine) && ok;
+ if (oneLine) {
+ if (a.y != d.y) ok = fail(id, "the buttons are not on one line");
+ if (a.x + a.width > d.x) ok = fail(id, "the action button does not come before the dismiss button");
+ } else {
+ if (d.y < a.y + a.height) ok = fail(id, "the dismiss button does not wrap below the action button");
+ }
+ return ok;
+}
+
+// Presses a part of one alert, so two alerts with the same label cannot be
+// confused.
+bool clickPart(const char *id, const char *partClass)
+{
+ const int node = partOf(id, partClass);
+ if (node < 0 || !gea::embedded::test::dispatchClick(node)) return fail("click", std::string("cannot press .") + partClass + " in #" + id);
+ advance();
+ return true;
+}
+
+bool clickText(const char *label)
+{
+ if (!gea::embedded::test::clickFirstText(label, true)) return fail("click", std::string("no button labelled ") + label);
+ advance();
+ return true;
+}
+} // namespace
+
+int main()
+{
+ using namespace gea::embedded::test;
+
+ resetNativeHost();
+ setNativeDisplaySize(kViewportWidth, kViewportHeight);
+ gea::framework::app::Application::init(kViewportWidth, kViewportHeight, kDevicePixelRatio);
+ __gea_top_level();
+ refresh();
+ gea::framework::app::Application::frame(16);
+
+ bool ok = true;
+
+ // Variants, with the caller's id and class on the root. alert-info passes
+ // no variant, so it checks the default.
+ ok = expectVariant("alert-info", "alert-info", "info") && ok;
+ ok = expectVariant("alert-success", "alert-success", "success") && ok;
+ ok = expectVariant("alert-warning", "alert-warning", "warning") && ok;
+ ok = expectVariant("alert-error", "alert-error", "error") && ok;
+ ok = expectVariant("alert-plain", "alert-plain", "error") && ok;
+
+ // The severity is spelled out, so it does not rely on color. The error
+ // alert passes a translated label; the plain one uses the default.
+ ok = expectPartText("alert-info", "inline-alert__severity", "Info") && ok;
+ ok = expectPartText("alert-success", "inline-alert__severity", "Success") && ok;
+ ok = expectPartText("alert-warning", "inline-alert__severity", "Warning") && ok;
+ ok = expectPartText("alert-error", "inline-alert__severity", "Hata") && ok;
+ ok = expectPartText("alert-plain", "inline-alert__severity", "Error") && ok;
+ ok = expectVariantColors() && ok;
+
+ // Errors are announced as alerts on the browser target, the rest as status.
+ ok = expectRole("alert-info", "status") && ok;
+ ok = expectRole("alert-warning", "status") && ok;
+ ok = expectRole("alert-error", "alert") && ok;
+
+ // Title and message are optional and render only when given; alert-success
+ // passes title={undefined}.
+ ok = expectPartText("alert-info", "inline-alert__title", "Update ready") && ok;
+ ok = expectPartText("alert-error", "inline-alert__title", "Yükleme başarısız") && ok;
+ ok = expectNoPart("alert-success", "inline-alert__title") && ok;
+ ok = expectPartText("alert-error", "inline-alert__message", "Dosya gönderilemedi.") && ok;
+ ok = expectPartOrder("alert-error") && ok;
+ ok = expectWrapped("alert-warning") && ok;
+
+ // Buttons render only when asked for, with the default or the given label.
+ ok = expectPartText("alert-info", "inline-alert__action", "Install") && ok;
+ ok = expectNoPart("alert-info", "inline-alert__dismiss") && ok;
+ ok = expectNoPart("alert-success", "inline-alert__action") && ok;
+ ok = expectPartText("alert-success", "inline-alert__dismiss", "Dismiss") && ok;
+ ok = expectPartText("alert-error", "inline-alert__dismiss", "Kapat") && ok;
+ ok = expectNoPart("alert-warning", "inline-alert__actions") && ok;
+ ok = expectNoPart("alert-plain", "inline-alert__actions") && ok;
+ ok = expectButtonFits("alert-info", "inline-alert__action") && ok;
+ ok = expectButtonFits("alert-error", "inline-alert__dismiss") && ok;
+ ok = expectBothButtons("alert-pair", true) && ok;
+ ok = expectBothButtons("alert-both", false) && ok;
+ ok = expectTitleWrapped("alert-both") && ok;
+ ok = expectPartText("alert-both", "inline-alert__dismiss", "Later") && ok;
+
+ // Empty title, message and action label render nothing; an empty severity
+ // or dismiss label falls back to the default instead of going blank.
+ ok = expectVariant("alert-empty", "alert-empty", "info") && ok;
+ ok = expectPartText("alert-empty", "inline-alert__severity", "Info") && ok;
+ ok = expectNoPart("alert-empty", "inline-alert__title") && ok;
+ ok = expectNoPart("alert-empty", "inline-alert__message") && ok;
+ ok = expectNoPart("alert-empty", "inline-alert__action") && ok;
+ ok = expectPartText("alert-empty", "inline-alert__dismiss", "Dismiss") && ok;
+ ok = expectStatus("initial", "installs=0 dismissCalls=0") && ok;
+
+ // The action calls onAction once per press and does not dismiss.
+ ok = clickPart("alert-info", "inline-alert__action") && ok;
+ ok = clickPart("alert-info", "inline-alert__action") && ok;
+ ok = expectStatus("after two installs", "installs=2 dismissCalls=0") && ok;
+ ok = expectVariant("alert-info", "alert-info", "info") && ok;
+
+ // Dismiss only reports: the app keeps alert-success, so it stays.
+ ok = clickPart("alert-success", "inline-alert__dismiss") && ok;
+ ok = expectStatus("after dismiss", "installs=2 dismissCalls=1") && ok;
+ ok = expectVariant("alert-success", "alert-success", "success") && ok;
+
+ // The error alert's app removes it on onDismiss; the others are untouched.
+ ok = clickPart("alert-error", "inline-alert__dismiss") && ok;
+ if (rootOf("alert-error") >= 0) ok = fail("after Kapat", "#alert-error is still in the tree");
+ if (!nodesWithText("Hata", true).empty()) ok = fail("after Kapat", "the removed alert's label is still shown");
+ ok = expectStatus("after Kapat", "installs=2 dismissCalls=1") && ok;
+ ok = expectVariant("alert-info", "alert-info", "info") && ok;
+ ok = expectVariant("alert-plain", "alert-plain", "error") && ok;
+
+ // A variant that the app changes re-renders with the new class, label and
+ // role.
+ ok = clickText("Downgrade") && ok;
+ ok = expectVariant("alert-plain", "alert-plain", "warning") && ok;
+ ok = expectPartText("alert-plain", "inline-alert__severity", "Warning") && ok;
+ ok = expectRole("alert-plain", "status") && ok;
+
+ // Each action reaches only its own alert's callback.
+ ok = clickPart("alert-both", "inline-alert__action") && ok;
+ ok = clickPart("alert-pair", "inline-alert__action") && ok;
+ ok = expectStatus("after two other actions", "installs=112 dismissCalls=1") && ok;
+
+ // A dismiss button without onDismiss does nothing when pressed.
+ ok = clickPart("alert-both", "inline-alert__dismiss") && ok;
+ ok = clickPart("alert-empty", "inline-alert__dismiss") && ok;
+ ok = expectStatus("after Later", "installs=112 dismissCalls=1") && ok;
+ ok = expectVariant("alert-both", "alert-both", "warning") && ok;
+
+ if (!ok) {
+ dumpTree(kTest);
+ return 1;
+ }
+ return 0;
+}
diff --git a/packages/elements/README.md b/packages/elements/README.md
index 14ff8f1..9525dc6 100644
--- a/packages/elements/README.md
+++ b/packages/elements/README.md
@@ -11,6 +11,18 @@ 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`.
+- `InlineAlert`: a persistent info, success, warning or error message built
+ from plain views and native `` elements, with an optional title,
+ action and dismiss button. A visible severity label (`severityLabel`, English
+ by default) backs up the colors. Dismissing only calls `onDismiss`; the app
+ removes the alert. The root has `role="status"` (`"alert"` for errors) for
+ screen readers on the browser target; native targets ignore it. With the
+ built-in bitmap font, pixel targets draw ASCII only, so a character such as
+ `ü` or `ş` draws as `?` unless the app enables runtime TTF fonts or a
+ `--gea-font-charset`. Right-to-left layout is not handled. In a native build,
+ an alert inside a conditional branch keeps the props it was first rendered
+ with; the InlineAlert demo in geastack/examples shows a shape that updates:
+ https://github.com/geastack/examples/tree/main/apps/inline-alert
```tsx
import { VirtualList } from '@geastack/elements'
diff --git a/packages/elements/components/InlineAlert.css b/packages/elements/components/InlineAlert.css
new file mode 100644
index 0000000..083e8c9
--- /dev/null
+++ b/packages/elements/components/InlineAlert.css
@@ -0,0 +1,80 @@
+.inline-alert {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ border-left: 4px solid #2f5f9e;
+ background-color: #e8eef7;
+ color: #171713;
+}
+
+.inline-alert--success {
+ border-left-color: #2e7d4f;
+ background-color: #e6f2ea;
+}
+
+.inline-alert--warning {
+ border-left-color: #a8690a;
+ background-color: #fbf0dc;
+}
+
+.inline-alert--error {
+ border-left-color: #b3261e;
+ background-color: #f9e3e1;
+}
+
+.inline-alert__body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.inline-alert__severity {
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.inline-alert__title {
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.inline-alert__message {
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.inline-alert__actions {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.inline-alert__action,
+.inline-alert__dismiss {
+ /* Measured on the pixel engine: an auto-width button in a flex row took
+ the whole row, where CSS gives it its max-content width. fit-content
+ sizes it to its label; max-width keeps a long label inside the row. */
+ width: fit-content;
+ max-width: 100%;
+ padding: 6px 12px;
+ border: 1px solid #171713;
+ background-color: #ffffff;
+ color: #171713;
+ font-size: 14px;
+}
+
+/* Keep this block last: it overrides the variant rules above at the same
+ specificity, and the severity label still tells the variants apart. */
+@media (monochrome) {
+ .inline-alert,
+ .inline-alert--success,
+ .inline-alert--warning,
+ .inline-alert--error {
+ border-left-color: #000000;
+ background-color: #ffffff;
+ color: #000000;
+ }
+}
diff --git a/packages/elements/components/InlineAlert.d.ts b/packages/elements/components/InlineAlert.d.ts
new file mode 100644
index 0000000..abd4446
--- /dev/null
+++ b/packages/elements/components/InlineAlert.d.ts
@@ -0,0 +1,62 @@
+import type { ClassValue } from '@geastack/core'
+
+export type InlineAlertVariant = 'info' | 'success' | 'warning' | 'error'
+
+export interface InlineAlertProps {
+ /** Defaults to `'info'`. Sets the colors and the default severity label. */
+ variant?: InlineAlertVariant
+ /** Bold heading above the message. Not rendered when empty. */
+ title?: string
+ /** Body text. Wraps to the alert's width. Not rendered when empty. */
+ message?: string
+ /**
+ * The visible severity text, so the variant does not rely on color alone.
+ * Defaults to `inlineAlertSeverityLabel(variant)` (English), also when
+ * empty; pass a translation for other languages. On pixel targets the
+ * built-in bitmap font draws ASCII only, so any other character, such as
+ * Turkish `ü`, `ş` or `ı`, draws as `?` unless the app enables runtime TTF
+ * fonts or names the characters in a `--gea-font-charset`.
+ */
+ severityLabel?: string
+ /** Text of the action button. The button is rendered only when this is not empty. */
+ actionLabel?: string
+ /** Called when the action button is pressed. */
+ onAction?: () => void
+ /** Show a dismiss button. Defaults to false. */
+ dismissible?: boolean
+ /** Text of the dismiss button. Defaults to `'Dismiss'`, also when empty; name what it closes when several alerts are on screen. */
+ dismissLabel?: string
+ /**
+ * Called when the dismiss button is pressed. The alert does not hide itself;
+ * remove it from the app's template here.
+ */
+ onDismiss?: () => void
+ /** Applied to the root. */
+ id?: string
+ /**
+ * Applied to the root; size and place the alert here. Restyle parts with
+ * descendant selectors such as `.your-class .inline-alert__title`; the parts
+ * are `inline-alert`, `inline-alert__body`, `__severity`, `__title`,
+ * `__message`, `__actions`, `__action` and `__dismiss`. Restyle a variant
+ * through its modifier, `.your-class .inline-alert--error`: a rule on
+ * `.your-class .inline-alert` overrides every variant's colors.
+ */
+ class?: ClassValue
+}
+
+/** The default English severity text for a variant: `'Info'`, `'Success'`, `'Warning'` or `'Error'`. */
+export declare function inlineAlertSeverityLabel(variant: InlineAlertVariant): string
+
+/**
+ * A persistent message inside a layout, composed from plain views and native
+ * `` elements. Buttons that do not fit on one line wrap to the next.
+ *
+ * Accessibility: on the browser target the root has `role="status"`, or
+ * `role="alert"` for errors, so a screen reader announces it. Native targets
+ * ignore the role and expose no status to assistive technology. The action
+ * and dismiss controls are native buttons.
+ *
+ * Layout: the stripe is on the left edge; right-to-left layouts are not
+ * handled.
+ */
+export default function InlineAlert(props: InlineAlertProps): any
diff --git a/packages/elements/components/InlineAlert.tsx b/packages/elements/components/InlineAlert.tsx
new file mode 100644
index 0000000..bee9ea5
--- /dev/null
+++ b/packages/elements/components/InlineAlert.tsx
@@ -0,0 +1,92 @@
+import type { ClassValue } from '@geastack/core'
+import './InlineAlert.css'
+
+// A persistent message inside a layout, composed from plain views and native
+// elements so it renders on pixel targets and desktop renderers
+// alike. The severity is spelled out in a text label as well as the colors,
+// so it does not rely on color alone; an empty label falls back to the
+// default so that cue is never blank. The app owns visibility: the dismiss
+// button only reports onDismiss, and the app removes the alert. The root
+// carries role="status" (role="alert" for errors) for the browser target;
+// native targets ignore the role.
+// The caller's `class` lands on the root; the alert's own parts use fixed
+// `inline-alert__*` classes. There is no `style` prop: geatsc has no rule for
+// forwarding a Style object through a function component.
+
+export type InlineAlertVariant = 'info' | 'success' | 'warning' | 'error'
+
+export interface InlineAlertProps {
+ variant?: InlineAlertVariant
+ title?: string
+ message?: string
+ severityLabel?: string
+ actionLabel?: string
+ onAction?: () => void
+ dismissible?: boolean
+ dismissLabel?: string
+ onDismiss?: () => void
+ id?: string
+ class?: ClassValue
+}
+
+export function inlineAlertSeverityLabel(variant: InlineAlertVariant): string {
+ if (variant === 'success') return 'Success'
+ if (variant === 'warning') return 'Warning'
+ if (variant === 'error') return 'Error'
+ return 'Info'
+}
+
+export default function InlineAlert({
+ variant,
+ title,
+ message,
+ severityLabel,
+ actionLabel,
+ onAction,
+ dismissible,
+ dismissLabel,
+ onDismiss,
+ id,
+ class: cls
+}: InlineAlertProps) {
+ const kind = variant === undefined ? 'info' : variant
+ const severity = severityLabel === undefined || severityLabel.length === 0 ? inlineAlertSeverityLabel(kind) : severityLabel
+ const heading = title === undefined ? '' : title
+ const text = message === undefined ? '' : message
+ const action = actionLabel === undefined ? '' : actionLabel
+ const canDismiss = dismissible === true
+ const closeText = dismissLabel === undefined || dismissLabel.length === 0 ? 'Dismiss' : dismissLabel
+ return (
+
+
+
+ {severity}
+ {heading.length > 0 ? {heading} : null}
+ {text.length > 0 ? {text} : null}
+
+ {action.length > 0 || canDismiss ? (
+
+ {action.length > 0 ? (
+ onAction?.()}>
+ {action}
+
+ ) : null}
+ {canDismiss ? (
+ onDismiss?.()}>
+ {closeText}
+
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
diff --git a/packages/elements/components/index.d.ts b/packages/elements/components/index.d.ts
index 91abb48..8fefd39 100644
--- a/packages/elements/components/index.d.ts
+++ b/packages/elements/components/index.d.ts
@@ -1,5 +1,7 @@
export { CameraView } from './CameraView'
export type { CameraViewProps } from './CameraView'
export type { CameraFacing, CameraFlashMode, CameraDevice, CameraOpenOptions, CameraCaptureOptions, CameraPhoto, CameraController } from '@geastack/core'
+export { default as InlineAlert, inlineAlertSeverityLabel } from './InlineAlert'
+export type { InlineAlertProps, InlineAlertVariant } from './InlineAlert'
export { default as VirtualList } from './VirtualList'
export type { VirtualListProps, VirtualListItemTemplate } from './VirtualList'
diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts
index 992a63c..6661d27 100644
--- a/packages/elements/components/index.ts
+++ b/packages/elements/components/index.ts
@@ -1,2 +1,4 @@
export { CameraView } from './CameraView'
+export { default as InlineAlert, inlineAlertSeverityLabel } from './InlineAlert'
+export type { InlineAlertProps, InlineAlertVariant } from './InlineAlert'
export { default as VirtualList } from './VirtualList'
diff --git a/packages/elements/package.json b/packages/elements/package.json
index a28afec..3a5bd13 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/InlineAlert": {
+ "types": "./components/InlineAlert.d.ts",
+ "default": "./components/InlineAlert.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"
}