Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,43 +1,53 @@
name: Release package
name: Stage stable package
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Exact release tag, such as v0.1.0-alpha.0
required: true
type: string

env:
RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}
NPM_CONFIG_USERCONFIG: /dev/null

concurrency:
group: npm-publish-${{ github.event.release.tag_name || inputs.tag }}
group: npm-stage-react-anchored-layer-stable
cancel-in-progress: false

permissions:
contents: read
id-token: write

jobs:
release:
stage:
if: github.ref == 'refs/heads/main'
environment: npm
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.RELEASE_TAG }}
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
package-manager-cache: false
- run: npm install --global [email protected]
- run: npm ci
- run: npm run check
- run: npx playwright install --with-deps chromium firefox webkit
- run: npm run test:e2e
- run: npm run test:website:e2e
- run: node scripts/verify-release.mjs --tag="${RELEASE_TAG}" --ensure-unpublished
- if: ${{ github.event_name == 'release' }}
run: npm publish --provenance --access public --tag alpha
- run: node scripts/verify-release.mjs --ensure-unpublished
- run: node scripts/verify-release.mjs --verify-current-main
- run: node scripts/pack-artifact.mjs
- run: ANCHORED_LAYER_PACKAGE_TARBALL=./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz npm run test:package
- uses: actions/upload-artifact@v7
with:
name: npm-react-anchored-layer-1.0.0-${{ github.sha }}
path: artifacts/
if-no-files-found: error
retention-days: 7
- run: node scripts/pack-artifact.mjs --verify-retained
- run: node scripts/verify-release.mjs --ensure-unpublished
- run: node scripts/verify-release.mjs --verify-current-main
- run: npm stage publish ./artifacts/nipe-solutions-react-anchored-layer-1.0.0.tgz --ignore-scripts --provenance --access public --tag latest
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ test-results/
website/dist/
.vercel
.env*
**/.DS_Store
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@

All notable changes to this project are documented here.

## 0.1.0-alpha.0 — Unreleased
## 1.0.0 — 2026-09-30

- Promote the existing anchored positioning API to a stable release with React
18.3 and React 19 consumer verification.
- Preserve React 19 callback-ref cleanup and keep refs attached through ordinary
renders. Reject fragments as `asChild` anchors.
- Preserve anchor direction across portals for correct RTL alignment and
transform origins, including content narrower than its anchor.
- Use the SSR-safe layout effect for content bookkeeping.
- Validate installed declarations, module formats, open-root server rendering,
and CSS exports against both supported React versions.
- Stage retained package bytes through trusted publishing before approval and
verify current main, exact version, integrity, and package contents.
- Update the stable documentation, security support policy, and transitive
development dependency `brace-expansion` to its patched version.

## 0.1.0-alpha.0

- Add controlled and uncontrolled anchored-layer roots.
- Add dependency-free `asChild` anchor composition for React 18.3 and React 19.
Expand Down
37 changes: 29 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,32 @@ visibility. Interaction semantics belong in consuming applications.

## Releases

Run `npm run release:check` before preparing a release. Publishing starts only
when a GitHub Release is published and its tag exactly matches the package
version (for example, `v0.1.0-alpha.0`). The workflow runs the complete quality
gate and browser matrix, rejects versions that already exist on npm, and then
publishes the public package with provenance under the `alpha` dist-tag.

The `npm` GitHub environment must be registered as the package's trusted
publisher on npm. Manual workflow dispatch verifies a tag but cannot publish.
Run `NPM_CONFIG_USERCONFIG=/dev/null npm run release:check` before preparing the
stable release. The release policy requires exactly `1.0.0`, public access,
provenance, and the `latest` dist-tag. A supplied release tag must be `v1.0.0`.

After the release PR passes CI and is merged, manually dispatch
`.github/workflows/release.yml` from `main`. Keep this filename registered with
the npm trusted publisher, with the protected `npm` GitHub environment, and
enable its permission to stage packages. The workflow installs npm 11.19.0,
runs the full quality gate and the Chromium, Firefox, WebKit, and website
browser suites, and fails closed if the npm version lookup is unsuccessful or
the version already exists. It compares the checked-out commit, `GITHUB_SHA`,
and a fresh remote `refs/heads/main` lookup immediately before preparation and
again immediately before staging. If main moves, dispatch again from its new
commit.

The workflow packs once and uploads the retained tarball, SHA-512 checksum, and
`release-artifact.json` containing its source commit and publication policy. It
rechecks those bytes, the embedded package manifest, and the exact package file
surface, and runs the React 18 and 19 runtime, SSR, type, and CSS consumer tests
on that exact retained tarball before uploading it. It then runs
`npm stage publish` on that tarball with public access,
provenance, and `latest`. GitHub Releases do not trigger staging or publication.

Before approving the staged package, download its bytes and compare their
SHA-512 checksum to the workflow artifact. Check the source commit, package
identity, version, and provenance. Approve the exact stage through npm's 2FA
approval flow only after that review. Record the stage ID and integrity alongside
the release evidence, then create the `v1.0.0` tag and GitHub Release at that
verified source commit. This repository's workflow never publishes directly.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Anchored floating layers for React.

This repository is under active development toward `0.1.0-alpha.0`.
Version `1.0.0` is the stable release.

React Anchored Layer renders arbitrary content through a portal and keeps it
aligned with an anchor through scrolling, resizing, and layout changes. It does
Expand All @@ -15,11 +15,11 @@ positioning engine.
## Installation

```sh
npm install @nipe-solutions/react-anchored-layer
npm install --save-exact @nipe-solutions/react-anchored-layer
```

React and React DOM are peer dependencies. React 18.3 and React 19 are
supported.
supported. The package currently requires Node 24 for installation and development.

## Quick start

Expand Down Expand Up @@ -68,15 +68,19 @@ the following CSS variables:
Module import and server rendering are safe without DOM globals. Portal content
is established on the client after mounting.

See the [API reference](docs/API.md) for prop defaults, ref behavior, and portal
lifecycle details. Applications should render one anchor and one content per
root. Child components used with `asChild` must forward a ref to an HTMLElement.

## Compatibility and size

The package targets current Chromium, Firefox, and WebKit, with automated
Playwright coverage in all three engines. The published JavaScript is measured
with React, React DOM, and Floating UI external: the current build is about
2.2 kB gzip for ESM and 1.8 kB gzip for CommonJS. The packed prerelease artifact
is about 7.4 kB.
2.5 kB gzip for ESM and 2.1 kB gzip for CommonJS. The packed artifact is kept
below 15 kB.

Known initial limitations are deliberate: there are no virtual anchors, arrow,
The scope is deliberate: there are no virtual anchors, arrow,
raw middleware API, automatic offscreen dismissal, vertical-writing-mode claim,
or global stacking coordinator.

Expand All @@ -85,8 +89,10 @@ or global stacking coordinator.
Use Node 24 and npm 11.

```sh
npm install
npm ci
npm run check
npm run test:e2e
npm run test:website:e2e
```

## License
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Supported versions

This project is in prerelease development. Security fixes target the latest
published prerelease.
Security fixes target the latest stable release in the `1.x` series. Upgrade
to the current `latest` version before reporting a suspected vulnerability.

## Reporting a vulnerability

Expand Down
86 changes: 86 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# API reference

The five components are available as named exports and through the frozen
`AnchoredLayer` namespace. React and React DOM 18.3 and 19 are supported.
Use one `Anchor` and one `Content` per `Root`; independent layers need independent
roots. Import `core.css` for positioning mechanics.

## Root

| Prop | Default | Meaning |
| -------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
| `open` | Uncontrolled | Controlled visibility. A requested change invokes `onOpenChange` without changing this prop. |
| `defaultOpen` | `false` | Initial uncontrolled visibility; subsequent prop changes do not reset it. |
| `onOpenChange` | None | Notification for a requested visibility change. Root does not add trigger, dismissal, or keyboard behavior. |

Choose controlled or uncontrolled state for the lifetime of a root. Closed
content unmounts, including its positioning subscriptions and refs.

## Anchor

The default anchor is a `span`. `asChild` uses exactly one element instead.
Pass a DOM element or a component that forwards its ref to an HTMLElement;
strings and fragments cannot register an anchor. The child's existing props
take precedence over props on `Anchor`, including event handlers. Event handlers
are not automatically combined.

The child ref, forwarded anchor ref, and internal registration are composed.
Unchanged refs remain attached during ordinary renders. React 19 callback-ref
cleanup runs on detach; refs without cleanup receive `null`. Replacing the anchor
hides content until positioning for the new element is established.

## Content

Content forwards its ref to a `div` and accepts normal div attributes, roles,
ARIA attributes, events, and styles. Positioning and first-position visibility
styles take precedence over conflicting consumer styles.

| Prop | Default | Meaning |
| ------------------- | ------------------------------ | ------------------------------------------------------------------------------- |
| `placement` | `bottom-start` | `top`, `right`, `bottom`, or `left`, optionally followed by `-start` or `-end`. |
| `strategy` | `absolute` | CSS positioning strategy; `fixed` is also available. |
| `offset` | `4` | Pixel gap, or `{ mainAxis, crossAxis }` offsets. |
| `avoidCollisions` | `true` | Flip and shift against clipping boundaries. |
| `collisionBoundary` | Floating UI clipping ancestors | One HTMLElement or an array of elements. |
| `collisionPadding` | `8` | Pixel inset from the collision boundary. |
| `matchAnchorWidth` | `false` | Apply measured anchor width, overriding a consumer width. |

Keep numeric geometry options finite. Supply connected elements for explicit
collision boundaries. Width matching and available-size variables do not add
scrolling or limit the content's height: set `max-height` and `overflow` as needed.

Content inherits the anchor's text direction across its portal unless an explicit
`dir` or directional style is supplied. Start/end and transform origin follow
that direction. Vertical writing modes are outside the supported contract.

Content is omitted while closed or without an anchor. Its first unpositioned
render is hidden and ignores pointer events. Once positioned it exposes
`data-positioned="true"`, `data-state="open"`, `data-side`, and `data-align`.

Measured CSS variables:

- `--anchored-layer-anchor-width`
- `--anchored-layer-anchor-height`
- `--anchored-layer-available-width`
- `--anchored-layer-available-height`
- `--anchored-layer-transform-origin`

## Portal and Provider

Content uses a body portal unless wrapped in an explicit `Portal`. The destination
is the Portal's `container`, then the nearest Provider's `portalRoot`, then
`document.body`. An undefined or null custom root falls back to the next choice.
A detached explicit root suppresses content instead of switching to another root.
After attaching or replacing a root, rerender the component so it can resolve the
destination again. The portal does not observe external removal of its container.

React context is preserved across a portal. DOM inheritance and modal focus
boundaries depend on the destination; choose a root within the modal when needed.
Server rendering omits portal children and establishes them after client mount.

## Application responsibilities

The package supplies no automatic role, focus movement, outside-press handling,
Escape dismissal, keyboard selection, or offscreen dismissal. Applications own
these behaviors and the semantics of the surface they render. Virtual anchors,
arrows, raw middleware, and global stacking coordination are not provided.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nipe-solutions/react-anchored-layer",
"version": "0.1.0-alpha.0",
"version": "1.0.0",
"description": "Anchored floating layers for React.",
"homepage": "https://react-anchored-layer.nipesolutions.com",
"repository": {
Expand Down Expand Up @@ -39,7 +39,7 @@
"publishConfig": {
"access": "public",
"provenance": true,
"tag": "alpha"
"tag": "latest"
},
"scripts": {
"test": "vitest run",
Expand All @@ -57,7 +57,7 @@
"test:website": "node scripts/verify-website.mjs",
"test:website:e2e": "playwright test --config playwright.website.config.ts",
"test:e2e": "playwright test",
"test:workflows": "node --test scripts/verify-workflows.test.mjs scripts/verify-release.test.mjs && node scripts/verify-workflows.mjs",
"test:workflows": "node --test scripts/verify-workflows.test.mjs scripts/verify-release.test.mjs scripts/pack-artifact.test.mjs && node scripts/verify-workflows.mjs",
"check": "npm run format:check && npm run lint && npm run typecheck && npm run test:unit && npm run test:types && npm run build:dist && npm run test:api && npm run test:size && npm run test:package && npm run build:website && npm run test:website && npm run test:workflows",
"release:check": "npm run check && node scripts/verify-release.mjs --dry-run"
},
Expand Down
Loading
Loading