diff --git a/.changeset/build-scope-source-condition.md b/.changeset/build-scope-source-condition.md
index 1543adb6b18d..171a18cffb13 100644
--- a/.changeset/build-scope-source-condition.md
+++ b/.changeset/build-scope-source-condition.md
@@ -4,7 +4,7 @@
[fix] Scope the `source` resolve condition to @astryxdesign packages in withAstryx
-`withAstryx` set webpack's `conditionNames` to `['source', …]` globally, which resolved *any* dependency shipping a `source` export to its raw TypeScript — not just Astryx packages. Third-party deps that ship a `source` export (e.g. `lexical`, pulled in by the new RichTextEditor lab component) were then fed untranspiled `.ts` through Next's babel and failed on syntax like `declare` class fields.
+`withAstryx` set webpack's `conditionNames` to `['source', …]` globally, which resolved _any_ dependency shipping a `source` export to its raw TypeScript — not just Astryx packages. Third-party deps that ship a `source` export (e.g. `lexical`, pulled in by the new RichTextEditor lab component) were then fed untranspiled `.ts` through Next's babel and failed on syntax like `declare` class fields.
The `source` condition is now applied via a scoped **allowlist** `module.rules` entry that only matches `node_modules/@astryxdesign/*` (with `conditionNames: ['source', '...']`, so it augments rather than replaces Next's defaults). The global `config.resolve.conditionNames` is left as Next's default, so all other packages — including any future third-party dep that happens to ship a `source` export — resolve to their built output. React JSX resolution is preserved via the inherited `'...'` defaults + `react-server` condition. Astryx source builds are unaffected.
diff --git a/.changeset/table-row-status.md b/.changeset/table-row-status.md
new file mode 100644
index 000000000000..033cbacd6ddf
--- /dev/null
+++ b/.changeset/table-row-status.md
@@ -0,0 +1,20 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] Add `useTableRowStatus`, a plugin that prepends a narrow column
+signaling per-row status.
+
+- Compact per-row status signal (error, warning, unread, etc.) without a
+ dedicated status column: a colored status dot by default, or an icon when
+ provided.
+- `getStatus(item)` maps a row to `{color, icon?, label?}`, or `null` for no
+ indicator. `color` accepts a semantic status name
+ (`success`/`error`/`warning`/`accent`/`red`/`green`/etc.) mapped to a theme
+ token, or a raw CSS color as an escape hatch.
+- `icon` renders the status as a shape signifier instead of the dot, which is
+ more accessible than color alone when multiple statuses coexist. `label`
+ supplies the accessible name.
+- Memoize `getStatus` with `useCallback` for a stable plugin identity.
+
+@humbertovirtudes
diff --git a/.github/instructions/packages.instructions.md b/.github/instructions/packages.instructions.md
index 56391c9fbdab..7efa79220925 100644
--- a/.github/instructions/packages.instructions.md
+++ b/.github/instructions/packages.instructions.md
@@ -1,5 +1,5 @@
---
-applyTo: "packages/**"
+applyTo: 'packages/**'
---
# Package review instructions
@@ -9,25 +9,25 @@ against Astryx's API guidance and component review protocol.
## Step 0 — Triage first: categorize, assess risk, pick a path
-Before reviewing in depth, do a fast triage. It decides *how hard* to look and
+Before reviewing in depth, do a fast triage. It decides _how hard_ to look and
in what order, so effort lands where the risk is — and so the risk checks
(especially breaking changes) happen **up front**, not as an afterthought.
**1. Categorize the PR** (by what it changes, not just the title prefix):
-| Category | Signals |
-|---|---|
+| Category | Signals |
+| ----------------------- | ----------------------------------------------------------------------------------------- |
| **test / docs / chore** | only `*.test.tsx`, `*.doc.mjs`, stories, CI, build config — no shipped runtime/API change |
-| **bug fix** | behavior change to existing code, no new public surface |
-| **new API surface** | new component, new prop/variant, new exported hook/type, changed signature |
-| **refactor / internal** | behavior-preserving restructure, shared-util migration |
+| **bug fix** | behavior change to existing code, no new public surface |
+| **new API surface** | new component, new prop/variant, new exported hook/type, changed signature |
+| **refactor / internal** | behavior-preserving restructure, shared-util migration |
**2. Assess risk — the two questions that gate everything:**
- **Is it a breaking change?** Scan for: a removed/renamed public export, prop,
or variant; a **new required** field on a public type/context/props; a changed
default; a changed function signature; or changed DOM/class/ARIA output
- consumers may depend on. (The tell for an *accidental* breaking change:
+ consumers may depend on. (The tell for an _accidental_ breaking change:
unrelated tests/examples/call sites had to be edited — see the silent-breaking
rule in Judgment.) A real breaking change must be **intentional and signalled
with a `[breaking]` changeset category** (pre-1.0 stays a `patch` bump — never
@@ -63,8 +63,8 @@ in what order, so effort lands where the risk is — and so the risk checks
> reviewer must **not**
> approve or merge it — even if it's clean, additive, and passing CI. Flag it as
> **⚠️ Needs human/maintainer judgment on the API surface** and leave the
-> approve/merge decision to a human. "Additive and non-breaking" is *not*
-> sufficient to auto-approve API surface — whether the API *should exist* and
+> approve/merge decision to a human. "Additive and non-breaking" is _not_
+> sufficient to auto-approve API surface — whether the API _should exist_ and
> take this shape is a human call. Behavior-only fixes, tests, docs, and chores
> from contributors can still take the fast/standard path.
@@ -77,7 +77,7 @@ breaking-change question is answered on every PR.
The shared **Review buckets** and **Review Signal** model lives in the root
`copilot-instructions.md` — apply it. This section defines what counts as
-**high-risk** *within `packages/**`*, which is what drives the signal:
+**high-risk** _within `packages/**`_, which is what drives the signal:
A change in this scope is **high-risk** when it involves any of:
@@ -92,7 +92,7 @@ A change in this scope is **high-risk** when it involves any of:
breaking change to a shared type/context (see Judgment).
Anything else in this scope is **low-risk** for signal purposes. The one
-explicitly low-risk *area* inside `packages/**` is **`packages/themes/**`**
+explicitly low-risk _area_ inside `packages/**` is **`packages/themes/**`**
(theme values); a theme-only change does not trip the high-risk gate — though
still apply the design blast-radius check in Judgment (a token change can
regress everywhere it composites).
@@ -101,13 +101,13 @@ The high-risk determination is also computed deterministically by
`.github/workflows/review-signal.yml`, which applies the **`needs:code-review`**
label and disables auto-merge on high-risk PRs. If the PR carries that label,
lead with 🔴 and focus on the high-risk surface (see the label note in the root
-instructions). Frame the review by bucket — the bucket sets *tone*, the area
-sets the *gate*:
+instructions). Frame the review by bucket — the bucket sets _tone_, the area
+sets the _gate_:
-- **Contributor** → your review is the *initial pass* that tells a code owner
+- **Contributor** → your review is the _initial pass_ that tells a code owner
where to focus. For a new prop / API change / new component / new package, add
the explicit **⚠️ Needs human/maintainer judgment on the API surface** note —
- additive and passing CI is *not* sufficient to imply approval.
+ additive and passing CI is _not_ sufficient to imply approval.
- **Design owner** → same checks, framed for a designer: name what crosses into
engineering territory and needs an engineer's eye.
- **Eng team** → assistant framing: surface the same findings as input to the
@@ -124,11 +124,11 @@ Once triaged, weight the review by what the PR is trying to do:
passing evidence and ask for it — a fix without a regression test can silently
break again.
- **Tests** — a test PR must earn its merge by testing the **contract, not the
- implementation**. A passing test that exists is necessary but *not* sufficient
+ implementation**. A passing test that exists is necessary but _not_ sufficient
to approve; judge it against the bar below and, if it's implementation-coupled
or padding, request changes (kindly) naming the specific assertions to cut or
- refocus. The one-line test: *does this protect a promise a consumer relies on,
- or does it just mirror the code?* If it would break on a harmless refactor yet
+ refocus. The one-line test: _does this protect a promise a consumer relies on,
+ or does it just mirror the code?_ If it would break on a harmless refactor yet
survive a real bug, it's slop.
- **✅ Worth testing** — the public behavioral contract (state transitions,
controlled/uncontrolled, callbacks fire with the right args, documented edge
@@ -141,14 +141,14 @@ Once triaged, weight the review by what the PR is trying to do:
(change-detector tests); snapshot dumps with no behavioral assertion;
re-testing React or the library ("useState updates", "prop passed through"
with no logic between); trivial padding ("renders without crashing" as the
- *only* assertion, or `it.each` explosions with no distinct risk per case);
+ _only_ assertion, or `it.each` explosions with no distinct risk per case);
and computed style/token/pixel assertions (that's brittle design territory).
- **Docs** — validate against reality. Check that the documentation is actually
correct (matches the code/API/behavior on this branch). When a claim is a
matter of best practice or judgment rather than fact, **call it out for a
maintainer** rather than asserting it's right or wrong.
-- **New features / new components** — whether this is the *right way to expose
- the functionality* is a human judgment call. **Do not render a verdict on the
+- **New features / new components** — whether this is the _right way to expose
+ the functionality_ is a human judgment call. **Do not render a verdict on the
API design here.** You may still run the mechanical, convention, and
convergence checks below, but explicitly **flag that maintainers should review
the API surface carefully** (and that new surface should be spec'd and
@@ -167,7 +167,7 @@ Add an explicit **"⚠️ Needs engineering / human judgment"** note when the ch
involves any of:
- **New public API surface or an API-shape decision** — a new component, a new
- prop/variant, or changing an existing prop's contract. (The *right* shape is a
+ prop/variant, or changing an existing prop's contract. (The _right_ shape is a
human call — see New features above.)
- **New runtime complexity** — effects, refs, observers
(`MutationObserver`/`ResizeObserver`/`IntersectionObserver`), imperative DOM
@@ -182,15 +182,15 @@ involves any of:
- **Escape hatches / breaking the system** — raw CSS, non-token values,
`swizzle`d source, or overriding a system default; these need a documented
rationale an engineer signs off on.
-- **Anything the change *asserts* works but can't be verified from the diff** —
+- **Anything the change _asserts_ works but can't be verified from the diff** —
performance claims, cross-browser/RTL/theme behavior, SSR/hydration.
Pure presentational work well within the system — composing existing components,
using tokens and documented props, adding a story or realistic mock data — does
**not** need this flag. Reserve it for the cases above so it stays meaningful.
-When you raise it, be specific: name *what* in the diff needs the deeper look and
-*why* (e.g. "the `ResizeObserver` in `X.tsx` is a runtime-complexity + perf
+When you raise it, be specific: name _what_ in the diff needs the deeper look and
+_why_ (e.g. "the `ResizeObserver` in `X.tsx` is a runtime-complexity + perf
decision — an engineer should confirm a container query wouldn't do"), so the
designer knows exactly what to hand off.
@@ -228,7 +228,7 @@ don't evaluate it in isolation. First check whether other components already
express the same capability, and push to converge on the existing shape:
- **Search for prior art.** Look for existing components with a prop of similar
- *purpose* or *behavior* — the same axis of variation, even under a different
+ _purpose_ or _behavior_ — the same axis of variation, even under a different
name (e.g. `size` vs `scale`, `isLoading` vs `busy`, `tone` vs `variant` vs
`color`, `density` vs `compact`). Comparable components should already be
siblings in the same family; check those first, then the wider system.
@@ -250,12 +250,12 @@ express the same capability, and push to converge on the existing shape:
### Plugins & hooks that extend a host component
Some components expose a plugin/hook surface (e.g. `Table` with
-`useTable*` plugins). These extend a host, so review them for consistency *with
-that host*, not in isolation — recurring issues seen in real review:
+`useTable*` plugins). These extend a host, so review them for consistency _with
+that host_, not in isolation — recurring issues seen in real review:
- **Mirror the host's API shape, in and out.** A plugin should accept and return
the same shapes the host already uses. If `Table` accepts `idKey` (a key that
- may be a string *or* a getter, so callers avoid writing callbacks), a plugin
+ may be a string _or_ a getter, so callers avoid writing callbacks), a plugin
should accept the same rather than forcing a bespoke callback — and should
**name its outputs to match the host's props** so they compose directly.
Prefer `const {idKey} = usePlugin();
` over exporting
@@ -264,7 +264,7 @@ that host*, not in isolation — recurring issues seen in real review:
- **Semantic values first, arbitrary as the escape hatch.** When a plugin/prop
takes a visual value (color, status, tone), the first-class API is the
system's **semantic tokens** (`color: 'accent'` / `'success'`), not raw values.
- Allowing arbitrary values is fine as an escape hatch, but the *default* shape
+ Allowing arbitrary values is fine as an escape hatch, but the _default_ shape
should be system semantics — flag an API where the raw/arbitrary form is the
primary one.
- **Decide host-level vs plugin-level deliberately** — especially for
@@ -292,7 +292,7 @@ For hooks (plugins or otherwise), watch two things that bit real Table PRs:
## Design review
-Some package changes are also *design* changes. When a diff affects how a
+Some package changes are also _design_ changes. When a diff affects how a
component **looks or behaves visually**, review it against
**[Design Conventions](https://github.com/facebook/astryx/wiki/Design-Conventions)** —
the design-side sibling of API Conventions — in addition to the checks below.
@@ -327,7 +327,7 @@ flag the concrete "smells" that page names:
arbitrary z-index and hairline-border-plus-diffuse-shadow or colored glows.
- **Typography** — role tokens; hierarchy ≥1.25 size ratio; body ≥12px; leading
≥1.3; flag flat hierarchy, all-caps/justified/gradient body, lines >~75ch.
-- **Color** — every fg/bg pair passes WCAG AA in light *and* dark; interaction
+- **Color** — every fg/bg pair passes WCAG AA in light _and_ dark; interaction
tints are alpha overlays (not opaque); status pairs color with an icon (never
color alone); one clear primary action; no pure `#000`/`#fff`.
- **Motion** — duration matches the change's weight; only `transform`/`opacity`
@@ -361,13 +361,13 @@ high-attention (post a note rather than hard-blocking — this is advisory).
**Flag a diff that adds a brand-new component directory under
`packages/core/src//` with no prior presence in `packages/lab/src/`.**
-That's a component skipping the staging step. (A lab→core *promotion* — a delete
+That's a component skipping the staging step. (A lab→core _promotion_ — a delete
under `packages/lab/src/**` paired with the add in core — is the healthy path
-and is fine; the concern is the *net-new* component that was never in lab.)
+and is fine; the concern is the _net-new_ component that was never in lab.)
When you see a net-new core component, ask the author to confirm either that it
went through lab, or that it genuinely meets the core bar that lab explicitly
-does *not* guarantee:
+does _not_ guarantee:
- Full keyboard + a11y (ARIA contracts, focus, `:hover` guarded by
`@media (hover: hover)`)
@@ -408,7 +408,7 @@ regardless of author, and confirm:
- **A tracking/spec issue is linked** establishing the need and scope.
Never approve a net-new package on convention-cleanliness alone; whether it
-*should exist* is a human call.
+_should exist_ is a human call.
### New template added already-visible (not `hidden`)
@@ -417,7 +417,7 @@ the template design bar. The CLI reads `hidden: true` and
`hiddenComponents: ['Name', ...]` from a template's `.doc.mjs`; hidden entries
are skipped from `--list`.
-**Flag a diff that adds a *new* template/block whose `.doc.mjs` is not
+**Flag a diff that adds a _new_ template/block whose `.doc.mjs` is not
`hidden: true`** (i.e. it's publicly listed from the moment it lands). A new
template appearing already-visible skipped the hidden-staging step and may not
be hardened yet. Ask the author to confirm it meets the template design bar
@@ -445,10 +445,10 @@ and the Design review section above), or to add `hidden: true` until it does.
- **Avoid unnecessary wrapper elements — prefer props and hooks.** Astryx favors
attaching behavior/style to existing elements over adding a new wrapper node.
Flag an added wrapper when a lighter path exists:
- - *Styling:* components extend `BaseProps` (they take `xstyle`), so apply style
+ - _Styling:_ components extend `BaseProps` (they take `xstyle`), so apply style
directly — `` — instead
of wrapping the component in a styled `
`.
- - *Behavior:* reach for the behavior **hook** or **prop** the system already
+ - _Behavior:_ reach for the behavior **hook** or **prop** the system already
exposes rather than a wrapper component. E.g. a tooltip is available via the
`tooltip` prop / `useTooltip` hook, and there are hooks for many behaviors
(`useHoverCard`, `useClickableContainer`, `useCollapsible`, `useFocusTrap`,
@@ -470,11 +470,11 @@ and the Design review section above), or to add `hidden: true` until it does.
or **`useTreeFocus`**; typeahead → **`useTypeahead`**; a keyboard-shortcut
hint → **`useKeyboardHint`**; focus trapping → **`useFocusTrap`**; interactive
role/state wiring → **`useInteractiveRole`**.
- These already implement the WAI-ARIA APG patterns and are tested, so reusing
- them keeps behavior consistent across the system. A genuinely new a11y pattern
- with no existing primitive is fine — but call it out for careful review (see
- "When to flag for engineering / human judgment") rather than landing a bespoke
- implementation quietly.
+ These already implement the WAI-ARIA APG patterns and are tested, so reusing
+ them keeps behavior consistent across the system. A genuinely new a11y pattern
+ with no existing primitive is fine — but call it out for careful review (see
+ "When to flag for engineering / human judgment") rather than landing a bespoke
+ implementation quietly.
- **Docs in sync** — JSDoc file headers, `SYNC:` reminders, and `.doc.mjs`.
`@example` fences in JSDoc must be plain ` ``` ` (never language-tagged), or
Storybook autodocs won't render them.
@@ -499,8 +499,8 @@ checks, flag it.
especially should be rare and deliberate.
- **Worsen the experience it's trying to help** — e.g. announcing on every
keystroke, or moving focus in a way that traps or disorients.
- Prefer announcing on a real state transition, debouncing/coalescing where the
- content is noisy, and reserving `assertive` for genuinely urgent messages.
+ Prefer announcing on a real state transition, debouncing/coalescing where the
+ content is noisy, and reserving `assertive` for genuinely urgent messages.
- **`useEffect` is a smell.** Treat a new/changed Effect as something to justify,
not accept by default. Most UI logic doesn't need one — look for whether it
belongs in an **event handler / callback** (logic that responds to a user
@@ -509,7 +509,7 @@ checks, flag it.
state). Use React's own guidance as the bar:
[You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
and [Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects).
- Genuine Effects synchronize with an *external* system (subscriptions, the DOM,
+ Genuine Effects synchronize with an _external_ system (subscriptions, the DOM,
network, non-React widgets) — those are fine; call out the ones that don't.
- **Overly complex behavior for a simple need.** Flag heavy runtime machinery
added where a simpler, declarative solution exists — the classic being a
@@ -532,18 +532,18 @@ checks, flag it.
instead — and whether the breaking change is worth it. (Real case: a new
required `aria-controls` id on a mobile-nav context forced edits to surfaces
that didn't otherwise need it.)
-- **Unintended behavior/logic change.** Compare what the diff *actually changes*
+- **Unintended behavior/logic change.** Compare what the diff _actually changes_
against what the PR says it does. Flag behavior the author likely didn't mean
to touch — a value, default, condition, or output that changed as a side effect
of the intended edit and that the description never mentions. This is different
- from scope contamination (unrelated *files* bundled in); here the collateral
- change is *inside* the area the author was working on, so it's easy to miss.
+ from scope contamination (unrelated _files_ bundled in); here the collateral
+ change is _inside_ the area the author was working on, so it's easy to miss.
Watch especially for:
- **Design/token changes — judge by presentation impact, not the token type.**
When a change touches a token, theme value, or style, reason about **how it
renders and where that could break**, not just that a value changed. Ask:
does this alter contrast, legibility, or emphasis? Does it change how a value
- *composites* over other surfaces or in a different theme/mode (a change that
+ _composites_ over other surfaces or in a different theme/mode (a change that
looks fine on the one screen the author checked but regresses elsewhere)?
Does it shift layout, spacing rhythm, or elevation order? Does it hold in
both light and dark, and against every surface the token appears on? The
@@ -557,11 +557,11 @@ checks, flag it.
(`>=` vs `>`), an early-return, or a guard that changed as a byproduct.
- **Generated-output drift** — a regenerated theme CSS / registry / token file
whose diff contains changes beyond the intended one.
- When you spot this, don't assume malice or intent — surface it as a question:
- "this also changes X (was `a`, now `b`) — does that hold everywhere it's used?"
- Naming it lets the author confirm or revert. The author being unaware, and
- having checked only the surface they were working on, is exactly why the
- reviewer checks the blast radius of a design change.
+ When you spot this, don't assume malice or intent — surface it as a question:
+ "this also changes X (was `a`, now `b`) — does that hold everywhere it's used?"
+ Naming it lets the author confirm or revert. The author being unaware, and
+ having checked only the surface they were working on, is exactly why the
+ reviewer checks the blast radius of a design change.
- **Other smells.** State expressed by unmounting focusable elements (toggle
visibility so focus/a11y survive), unnecessary `useState` (prefer derived
values or refs, especially from interaction handlers), and excessive comments.
diff --git a/apps/sandbox/README.md b/apps/sandbox/README.md
index 03d496f7a34e..0a1ae6f20fba 100644
--- a/apps/sandbox/README.md
+++ b/apps/sandbox/README.md
@@ -89,12 +89,12 @@ Edits trigger incremental dist rebuilds via Babel CLI (a few seconds), and CSS l
| File | Purpose |
| -------------------------- | ------------------------------------------------------------------------ |
-| `package.json` | Dependencies; uses PostCSS path for StyleX |
+| `package.json` | Dependencies; uses PostCSS path for StyleX |
| `babel.config.js` | StyleX babel plugin config (as plugin, not preset) |
-| `postcss.config.js` | StyleX PostCSS plugin: extracts CSS from `@stylex;` directive |
+| `postcss.config.js` | StyleX PostCSS plugin: extracts CSS from `@stylex;` directive |
| `next.config.mjs` | Static export, basePath for GitHub Pages, webpack alias for theme tokens |
| `tsconfig.json` | TypeScript config with workspace path aliases |
-| `src/app/globals.css` | `@stylex;` injection point: PostCSS replaces this with extracted CSS |
+| `src/app/globals.css` | `@stylex;` injection point: PostCSS replaces this with extracted CSS |
| `src/app/providers.tsx` | Client-side theme provider wrapper |
| `src/app/layout.tsx` | Root layout with sidebar navigation |
| `src/app/Sidebar.tsx` | Sidebar navigation component |
diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx
index da943088c3d4..141f0a593166 100644
--- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx
+++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx
@@ -49,6 +49,7 @@ import {
useTableRowExpansionState,
useTableGroupedRows,
useTableRowIndex,
+ useTableRowStatus,
} from '@astryxdesign/core/Table';
import type {TablePlugin, TableSortState} from '@astryxdesign/core/Table';
@@ -72,7 +73,8 @@ type PluginId =
| 'stickyColumns'
| 'rowExpansion'
| 'groupedRows'
- | 'rowIndex';
+ | 'rowIndex'
+ | 'rowStatus';
interface PluginMeta {
id: PluginId;
@@ -117,6 +119,11 @@ const PLUGIN_REGISTRY: PluginMeta[] = [
label: 'Row Index',
description: 'Prepend a monospaced row-number column',
},
+ {
+ id: 'rowStatus',
+ label: 'Row Status',
+ description: 'Status dot / icon (by member status)',
+ },
];
// =============================================================================
@@ -244,6 +251,21 @@ function useLabPlugins({
getRowKey: item => item.id,
});
+ // --- row status ---
+ const rowStatusPlugin = useTableRowStatus({
+ getStatus: item => {
+ // Semantic color names map to theme tokens; an icon adds a shape
+ // differentiator so status isn't conveyed by color alone.
+ if (item.status === 'Active') {
+ return {color: 'success', icon: 'success', label: 'Active'};
+ }
+ if (item.status === 'Away') {
+ return {color: 'warning', icon: 'warning', label: 'Away'};
+ }
+ return null; // Offline: no indicator
+ },
+ });
+
// Assemble enabled plugins in a stable order.
const plugins: Record> = {};
if (enabled.groupedRows) {
@@ -252,6 +274,9 @@ function useLabPlugins({
if (enabled.rowIndex) {
plugins.rowIndex = rowIndexPlugin;
}
+ if (enabled.rowStatus) {
+ plugins.rowStatus = rowStatusPlugin;
+ }
if (enabled.sortable) {
plugins.sort = sortPlugin;
}
@@ -431,6 +456,7 @@ export default function TableLabPage() {
rowExpansion: false,
groupedRows: false,
rowIndex: false,
+ rowStatus: false,
});
const baseData = useMemo(() => generateRows(rowCount), [rowCount]);
diff --git a/apps/storybook/stories/RichTextEditor.stories.tsx b/apps/storybook/stories/RichTextEditor.stories.tsx
index 3638d80f3260..03998771184d 100644
--- a/apps/storybook/stories/RichTextEditor.stories.tsx
+++ b/apps/storybook/stories/RichTextEditor.stories.tsx
@@ -2,10 +2,7 @@
import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
-import {
- RichTextEditor,
- RichTextView,
-} from '@astryxdesign/lab';
+import {RichTextEditor, RichTextView} from '@astryxdesign/lab';
import type {EditorState} from 'lexical';
const meta: Meta = {
diff --git a/apps/storybook/stories/TableRowStatus.stories.tsx b/apps/storybook/stories/TableRowStatus.stories.tsx
new file mode 100644
index 000000000000..b53660aeaeb1
--- /dev/null
+++ b/apps/storybook/stories/TableRowStatus.stories.tsx
@@ -0,0 +1,101 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+import type {Meta, StoryObj} from '@storybook/react';
+import {
+ Table,
+ useTableRowStatus,
+ proportional,
+ pixel,
+} from '@astryxdesign/core/Table';
+import type {TableColumn, TableRowStatus} from '@astryxdesign/core/Table';
+
+// =============================================================================
+// Sample Data
+// =============================================================================
+
+interface Job extends Record {
+ id: string;
+ name: string;
+ owner: string;
+ state: 'failed' | 'running' | 'queued' | 'succeeded';
+}
+
+const jobs: Job[] = [
+ {id: 'j1', name: 'build-core', owner: 'Ava', state: 'failed'},
+ {id: 'j2', name: 'lint', owner: 'Liam', state: 'running'},
+ {id: 'j3', name: 'unit-tests', owner: 'Zoe', state: 'succeeded'},
+ {id: 'j4', name: 'docsite-deploy', owner: 'Max', state: 'queued'},
+ {id: 'j5', name: 'smoke-test', owner: 'Mia', state: 'succeeded'},
+];
+
+const columns: TableColumn[] = [
+ {key: 'name', header: 'Job', width: proportional(2)},
+ {key: 'owner', header: 'Owner', width: pixel(120)},
+ {key: 'state', header: 'State', width: pixel(120)},
+];
+
+function jobStatus(job: Job): TableRowStatus | null {
+ switch (job.state) {
+ case 'failed':
+ return {color: 'error', icon: 'error', label: 'Failed'};
+ case 'running':
+ return {color: 'warning', icon: 'warning', label: 'Running'};
+ case 'queued':
+ return {color: 'gray', label: 'Queued'};
+ default:
+ return null; // succeeded: no indicator
+ }
+}
+
+const meta: Meta = {
+ title: 'Core/TableRowStatus',
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * A small colored dot in a leading gutter column signals per-row status.
+ * Rows whose `getStatus` returns `null` (here: succeeded jobs) show no dot.
+ * Hover a dot to see its accessible label.
+ */
+export const Default: Story = {
+ render: () => {
+ const rowStatus = useTableRowStatus({getStatus: jobStatus});
+ return (
+
{/* Drawer panel — tabIndex so showModal() focuses the drawer, not the close button */}
diff --git a/packages/core/src/NumberInput/NumberInput.tsx b/packages/core/src/NumberInput/NumberInput.tsx
index f23c15af44ce..baee215d915a 100644
--- a/packages/core/src/NumberInput/NumberInput.tsx
+++ b/packages/core/src/NumberInput/NumberInput.tsx
@@ -311,8 +311,7 @@ type NumberInputPropsClearable = NumberInputPropsBase & {
};
export type NumberInputProps =
- | NumberInputPropsNonClearable
- | NumberInputPropsClearable;
+ NumberInputPropsNonClearable | NumberInputPropsClearable;
/**
* Parse and validate a string input as a number.
diff --git a/packages/core/src/PowerSearch/usePowerSearchSource.ts b/packages/core/src/PowerSearch/usePowerSearchSource.ts
index 8dfe36ce2dbb..f259b03f78bf 100644
--- a/packages/core/src/PowerSearch/usePowerSearchSource.ts
+++ b/packages/core/src/PowerSearch/usePowerSearchSource.ts
@@ -76,8 +76,7 @@ export function usePowerSearchSource(
// Check each field+operator combo against the query
for (const op of field.operators) {
- const combinedLabel =
- `${field.label} ${opLabel(op)}`.toLowerCase();
+ const combinedLabel = `${field.label} ${opLabel(op)}`.toLowerCase();
if (combinedLabel.includes(lower)) {
const id = `${field.key}:${op.key}`;
if (!seen.has(id)) {
diff --git a/packages/core/src/SideNav/SideNavCollapseButton.tsx b/packages/core/src/SideNav/SideNavCollapseButton.tsx
index 79d8fb32ac0c..db24b61c3c9d 100644
--- a/packages/core/src/SideNav/SideNavCollapseButton.tsx
+++ b/packages/core/src/SideNav/SideNavCollapseButton.tsx
@@ -120,7 +120,12 @@ export function SideNavCollapseButton({
return (
diff --git a/packages/core/src/Table/index.ts b/packages/core/src/Table/index.ts
index 62956d41ab76..b5efeee87d33 100644
--- a/packages/core/src/Table/index.ts
+++ b/packages/core/src/Table/index.ts
@@ -28,6 +28,7 @@ export {useTableColumnResize} from './plugins/columnResize';
export {useTableStickyColumns} from './plugins/stickyColumns';
export {useTableGroupedRows} from './plugins/groupedRows';
export {useTableRowIndex} from './plugins/rowIndex';
+export {useTableRowStatus} from './plugins/rowStatus';
export {
useTableRowExpansion,
useTableRowExpansionState,
@@ -110,6 +111,10 @@ export type {
UseTableGroupedRowsConfig,
UseTableGroupedRowsResult,
} from './plugins/groupedRows';
+export type {
+ UseTableRowStatusConfig,
+ TableRowStatus,
+} from './plugins/rowStatus';
export type {
TableTreeRowMeta,
UseTableTreeDataConfig,
diff --git a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
index 41b54f08a694..f18e90e67942 100644
--- a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
+++ b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx
@@ -325,8 +325,12 @@ export function useTableGroupedRows>(
}}
aria-label={
collapsed
- ? t('@astryx.tableGroupedRows.expandGroup', {groupKey: header.groupKey})
- : t('@astryx.tableGroupedRows.collapseGroup', {groupKey: header.groupKey})
+ ? t('@astryx.tableGroupedRows.expandGroup', {
+ groupKey: header.groupKey,
+ })
+ : t('@astryx.tableGroupedRows.collapseGroup', {
+ groupKey: header.groupKey,
+ })
}
aria-expanded={!collapsed}>
{
+ id: string;
+ name: string;
+ state: 'error' | 'warning' | 'ok' | 'done';
+}
+
+const data: Row[] = [
+ {id: 'a', name: 'Alice', state: 'error'},
+ {id: 'b', name: 'Bob', state: 'ok'},
+ {id: 'c', name: 'Carol', state: 'warning'},
+];
+
+const columns: TableColumn[] = [{key: 'name', header: 'Name'}];
+
+function getStatus(item: Row): TableRowStatus | null {
+ if (item.state === 'error') {
+ return {color: 'red', label: 'Error'};
+ }
+ if (item.state === 'warning') {
+ return {color: 'orange', label: 'Warning'};
+ }
+ return null;
+}
+
+function Harness({
+ rows = data,
+ statusFn = getStatus,
+}: {
+ rows?: Row[];
+ statusFn?: (item: Row) => TableRowStatus | null;
+}) {
+ const rowStatus = useTableRowStatus({getStatus: statusFn});
+ return (
+
+ );
+}
+
+describe('useTableRowStatus', () => {
+ it('prepends a narrow status column with an empty header', () => {
+ render();
+ const headers = screen.getAllByRole('columnheader');
+ // Status column is first; its header is empty.
+ expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus');
+ expect(headers[0].textContent).toBe('');
+ });
+
+ it('renders a labeled dot for rows with a status', () => {
+ render();
+ expect(screen.getByRole('img', {name: 'Error'})).toBeInTheDocument();
+ expect(screen.getByRole('img', {name: 'Warning'})).toBeInTheDocument();
+ });
+
+ it('renders a dot (not an icon) by default', () => {
+ render();
+ // Default (no icon) renders a plain colored dot: no svg in the indicator.
+ const dot = screen.getByRole('img', {name: 'Error'});
+ expect(dot.querySelector('svg')).toBeNull();
+ });
+
+ it('renders no indicator for rows returning null', () => {
+ render();
+ const rows = screen.getAllByRole('row');
+ // rows[2] is Bob (state ok): no status indicator in his status cell.
+ const bob = rows[2];
+ expect(within(bob).getByText('Bob')).toBeInTheDocument();
+ expect(within(bob).queryByRole('img')).not.toBeInTheDocument();
+ });
+
+ it('maps a semantic color name to its icon color token', () => {
+ render();
+ const errorDot = screen.getByRole('img', {name: 'Error'});
+ // 'red' resolves to var(--color-icon-red); StyleX emits it on the inline
+ // style of the inner dot element.
+ const dot = errorDot.querySelector('span');
+ expect(dot?.getAttribute('style')).toContain('--color-icon-red');
+ });
+
+ it('passes through a raw CSS color as an escape hatch', () => {
+ render(
+
+ item.state === 'error' ? {color: 'rgb(1, 2, 3)', label: 'Raw'} : null
+ }
+ />,
+ );
+ const indicator = screen.getByRole('img', {name: 'Raw'});
+ const dot = indicator.querySelector('span');
+ expect(dot?.getAttribute('style')).toContain('rgb(1, 2, 3)');
+ });
+
+ it('renders an icon as the status signifier when icon is provided', () => {
+ render(
+
+ item.state === 'error'
+ ? {color: 'red', icon: 'error', label: 'Error'}
+ : null
+ }
+ />,
+ );
+ // Icon-mode still exposes the accessible label via role=img.
+ const indicator = screen.getByRole('img', {name: 'Error'});
+ expect(indicator).toBeInTheDocument();
+ // An SVG icon is rendered inside the indicator (dot mode has no svg).
+ expect(indicator.querySelector('svg')).not.toBeNull();
+ });
+
+ it('exposes the required label as the accessible name in dot mode', () => {
+ render(
+
+ item.state === 'error' ? {color: 'red', label: 'Error'} : null
+ }
+ />,
+ );
+ // Label is required, so every dot is announced via role=img with its name.
+ const indicator = screen.getByRole('img', {name: 'Error'});
+ expect(indicator).toBeInTheDocument();
+ // Dot mode renders no svg (that is icon mode).
+ expect(indicator.querySelector('svg')).toBeNull();
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ });
+
+ it('renders the status header with empty data and no indicators', () => {
+ render();
+ const headers = screen.getAllByRole('columnheader');
+ expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus');
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx
new file mode 100644
index 000000000000..870c0d41916e
--- /dev/null
+++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx
@@ -0,0 +1,189 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useTableRowStatus.tsx
+ * @input React, StyleX, Icon, Table types
+ * @output Exports useTableRowStatus hook + config type
+ * @position Row-status plugin; consumed by Table via plugins prop
+ *
+ * SYNC: When modified, update these files to stay in sync:
+ * - /packages/core/src/Table/index.ts (exports)
+ */
+
+import {useMemo} from 'react';
+import * as stylex from '@stylexjs/stylex';
+import {Icon, type IconColor, type IconName} from '../../../Icon';
+import {Tooltip} from '../../../Tooltip';
+import type {TableColumn, TablePlugin} from '../../types';
+
+/**
+ * Semantic status colors, resolved to the design system's icon color tokens.
+ * Prefer these over raw CSS so status colors stay consistent with the theme.
+ */
+export type TableRowStatusColor =
+ | 'accent'
+ | 'success'
+ | 'error'
+ | 'warning'
+ | 'red'
+ | 'orange'
+ | 'green'
+ | 'yellow'
+ | 'blue'
+ | 'gray';
+
+const SEMANTIC_COLORS: Record = {
+ accent: 'var(--color-icon-accent)',
+ success: 'var(--color-icon-green)',
+ error: 'var(--color-icon-red)',
+ warning: 'var(--color-icon-orange)',
+ red: 'var(--color-icon-red)',
+ orange: 'var(--color-icon-orange)',
+ green: 'var(--color-icon-green)',
+ yellow: 'var(--color-icon-yellow)',
+ blue: 'var(--color-icon-blue)',
+ gray: 'var(--color-icon-gray)',
+};
+
+/** Icon colors that map cleanly from a semantic status color. */
+const ICON_COLOR_BY_STATUS: Record = {
+ accent: 'accent',
+ success: 'success',
+ error: 'error',
+ warning: 'warning',
+ red: 'red',
+ orange: 'warning',
+ green: 'green',
+ yellow: 'warning',
+ blue: 'blue',
+ gray: 'gray',
+};
+
+/**
+ * A row's status indicator. `color` accepts a semantic status color
+ * (mapped to a theme token) or any raw CSS color string as an escape hatch.
+ * By default the plugin renders a colored status dot. Provide `icon` to signal
+ * status by shape as well as color, which is more accessible when several
+ * statuses coexist. `label` is required so the status is never conveyed by
+ * color alone — it names the indicator for assistive technology and shows on
+ * hover. Return `null` for rows with no status.
+ */
+export interface TableRowStatus {
+ /** Semantic status color (preferred) or a raw CSS color string. */
+ color: TableRowStatusColor | (string & {});
+ /** Optional icon rendered as the signifier instead of the dot (shape as an a11y differentiator). */
+ icon?: IconName;
+ /**
+ * Accessible name for the status, announced to assistive technology and
+ * shown in a tooltip on hover. Required: a status must never be conveyed by
+ * color alone.
+ */
+ label: string;
+}
+
+/** Configuration for {@link useTableRowStatus}. */
+export interface UseTableRowStatusConfig> {
+ /**
+ * Derive the status indicator for a row. Return `null` for no indicator.
+ * Memoize with `useCallback` for a stable plugin identity across renders.
+ *
+ * @example
+ * ```
+ * getStatus: row =>
+ * row.hasError ? {color: 'error', icon: 'error', label: 'Error'} : null
+ * ```
+ */
+ getStatus: (item: T) => TableRowStatus | null;
+}
+
+// The status column holds a small centered dot (or an icon when provided).
+// A fixed narrow width keeps every row's indicator aligned in one gutter.
+const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 28};
+
+/** Resolve a semantic color name to a token, or pass a raw CSS color through. */
+function resolveColor(color: string): string {
+ return (SEMANTIC_COLORS as Record)[color] ?? color;
+}
+
+const styles = stylex.create({
+ // Centers the dot or icon within the narrow status column.
+ wrap: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ dot: (color: string) => ({
+ width: '8px',
+ height: '8px',
+ borderRadius: '50%',
+ backgroundColor: color,
+ flexShrink: 0,
+ }),
+});
+
+/**
+ * Returns a {@link TablePlugin} that prepends a narrow column signaling per-row
+ * status: a colored status dot by default, or an icon when `icon` is provided
+ * (shape + color is more accessible than color alone).
+ *
+ * @example
+ * ```
+ * const rowStatus = useTableRowStatus({
+ * getStatus: row =>
+ * row.state === 'error'
+ * ? {color: 'error', icon: 'error', label: 'Error'}
+ * : null,
+ * });
+ *
;
+ * ```
+ */
+export function useTableRowStatus>(
+ config: UseTableRowStatusConfig,
+): TablePlugin {
+ const {getStatus} = config;
+
+ return useMemo(
+ (): TablePlugin => ({
+ transformColumns(columns) {
+ const statusColumn: TableColumn = {
+ key: '__rowStatus',
+ header: '',
+ width: STATUS_COLUMN_WIDTH,
+ resizable: false,
+ renderCell: (item: T) => {
+ const status = getStatus(item);
+ if (!status) {
+ return null;
+ }
+ const signifier = status.icon ? (
+
+ ) : (
+
+ );
+ return (
+
+
+ {signifier}
+
+
+ );
+ },
+ };
+ return [statusColumn, ...columns];
+ },
+ }),
+ [getStatus],
+ );
+}
diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs
new file mode 100644
index 000000000000..b3e051ecaaf5
--- /dev/null
+++ b/packages/core/src/Table/useTableRowStatus.doc.mjs
@@ -0,0 +1,30 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/** @type {import('../docs-types').ComponentDoc} */
+
+export const docs = {
+ name: 'useTableRowStatus',
+ subComponentOf: 'Table',
+ displayName: 'useTableRowStatus',
+ description:
+ 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and a required accessible label (shown in a tooltip on hover and announced to assistive technology, so status is never color-only); return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.',
+ props: [
+ {
+ name: 'getStatus',
+ type: "(item: T) => { color: 'accent' | 'success' | 'error' | 'warning' | 'red' | 'orange' | 'green' | 'yellow' | 'blue' | 'gray' | string; icon?: IconName; label: string } | null",
+ description:
+ 'Derive the status indicator for a row: a semantic color (accent, success, error, warning, red, orange, green, yellow, blue, gray — mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist; valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone); and a required accessible label (announced via role="img" and shown in a tooltip on hover, so a status is never conveyed by color alone). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.',
+ required: true,
+ },
+ ],
+};
+
+/** @type {import('../docs-types').TranslationDoc} */
+export const docsDense = {
+ description:
+ 'Returns a TablePlugin that prepends a narrow per-row status column: a colored dot, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label} or null. color is a semantic name (success/error/warning/etc.) mapped to a theme token, or a raw CSS color. label is required (tooltip + accessible name). Memoize getStatus with useCallback.',
+ propDescriptions: {
+ getStatus:
+ 'Map a row to {color, icon?, label} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = required accessible name (tooltip + role="img"). Memoize with useCallback.',
+ },
+};
diff --git a/packages/core/src/authoring/doc.ts b/packages/core/src/authoring/doc.ts
index ef217628cc75..c6bde42a772e 100644
--- a/packages/core/src/authoring/doc.ts
+++ b/packages/core/src/authoring/doc.ts
@@ -130,9 +130,7 @@ export type AstryxGenericDocInput = AstryxBaseDocInput;
* any code referring to the old name.
*/
export type AstryxComponentDoc =
- | AstryxComponentDocInput
- | AstryxFunctionDocInput
- | AstryxGenericDocInput;
+ AstryxComponentDocInput | AstryxFunctionDocInput | AstryxGenericDocInput;
/**
* Author a component doc. Stamp-only: returns the def with `type: 'component'`
diff --git a/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx b/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx
index 4eec213d200b..f1b8b9207358 100644
--- a/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx
+++ b/packages/lab/src/RichTextEditor/RichTextEditor.test.tsx
@@ -14,11 +14,7 @@ import {render, screen, waitFor} from '@testing-library/react';
import {useEffect} from 'react';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import type {LexicalEditor} from 'lexical';
-import {
- $getRoot,
- $createParagraphNode,
- $createTextNode,
-} from 'lexical';
+import {$getRoot, $createParagraphNode, $createTextNode} from 'lexical';
import {HeadingNode} from '@lexical/rich-text';
import {RichTextEditor} from './RichTextEditor';
import {RichTextView} from './RichTextView';
@@ -218,9 +214,7 @@ describe('RichTextView', () => {
});
it('renders nothing (no crash) on malformed JSON with no fallback', () => {
- expect(() =>
- render(),
- ).not.toThrow();
+ expect(() => render()).not.toThrow();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
});
diff --git a/packages/lab/src/RichTextEditor/RichTextEditor.tsx b/packages/lab/src/RichTextEditor/RichTextEditor.tsx
index bbf3df34676b..f195344078f5 100644
--- a/packages/lab/src/RichTextEditor/RichTextEditor.tsx
+++ b/packages/lab/src/RichTextEditor/RichTextEditor.tsx
@@ -45,7 +45,10 @@ import type {SizeValue} from '@astryxdesign/core/utils';
import {useSize} from '@astryxdesign/core/SizeContext';
import {themeProps} from '@astryxdesign/core/utils';
-import {LexicalComposer, type InitialConfigType} from '@lexical/react/LexicalComposer';
+import {
+ LexicalComposer,
+ type InitialConfigType,
+} from '@lexical/react/LexicalComposer';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
@@ -143,8 +146,10 @@ export interface RichTextEditorStatus {
message?: string;
}
-export interface RichTextEditorProps
- extends Omit {
+export interface RichTextEditorProps extends Omit<
+ BaseProps,
+ 'onChange' | 'defaultValue'
+> {
/** Label text for the editor (always rendered for accessibility). */
label: string;
/**
@@ -313,7 +318,6 @@ export function RichTextEditor({
.filter(Boolean)
.join(' ') || undefined;
-
return (