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
6 changes: 1 addition & 5 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
]
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}
39 changes: 0 additions & 39 deletions .eslintrc

This file was deleted.

25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: 2

updates:
- package-ecosystem: npm
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 5
groups:
# One PR for the routine churn instead of one per package.
dev-dependencies:
dependency-type: development
update-types: [minor, patch]
ignore:
# Bumping the React types moves the public type surface of the package,
# so it should be a deliberate change rather than an automated one.
- dependency-name: '@types/react'
update-types: [version-update:semver-major]
- dependency-name: '@types/react-dom'
update-types: [version-update:semver-major]

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

# A newer push to the same branch makes the in-flight run irrelevant.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
# Node 22 LTS. It is the lower bound we support: jsdom 30 needs the undici
# that ships with Node 22+, and anything passing here passes on 24.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# Version comes from the "packageManager" field in package.json.
- uses: pnpm/action-setup@v4
with:
run_install: false

- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check formatting
run: pnpm run format:check

- name: Lint
run: pnpm run lint

- name: Type-check
run: pnpm run type-check

- name: Test
run: pnpm run test

- name: Build
run: pnpm run build

# Catches the case where `files` or the build output drift apart and the
# published tarball silently loses the entry point or its type definitions.
- name: Verify package contents
run: |
pnpm pack --pack-destination /tmp
tar tzf /tmp/react-xarrows-*.tgz | sort
tar tzf /tmp/react-xarrows-*.tgz | grep -q 'package/lib/index.js'
tar tzf /tmp/react-xarrows-*.tgz | grep -q 'package/lib/index.d.ts'
97 changes: 97 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Release and publish

# Publishing is driven by the version field in package.json. Bump it on master
# and this workflow builds, publishes to npm and cuts the matching GitHub release.
# If the version is unchanged the run is a no-op, so it is safe to re-run.
on:
push:
branches: [master]
paths:
- package.json
workflow_dispatch:

permissions:
contents: write # create the GitHub release and its tag
id-token: write # npm Trusted Publishing / provenance

concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5

# Version comes from the "packageManager" field in package.json.
- uses: pnpm/action-setup@v4
with:
run_install: false

- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm run lint

- name: Type-check
run: pnpm run type-check

- name: Test
run: pnpm run test

- name: Build
run: pnpm run build

- name: Read package info
id: pkg
run: |
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
echo "name=$NAME" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Resolved $NAME@$VERSION"

- name: Check if version is already published
id: check
run: |
NAME='${{ steps.pkg.outputs.name }}'
VERSION='${{ steps.pkg.outputs.version }}'
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
echo "publish=false" >> $GITHUB_OUTPUT
echo "$NAME@$VERSION is already on npm - nothing to do."
else
echo "publish=true" >> $GITHUB_OUTPUT
echo "$NAME@$VERSION is new - will publish."
fi

# Trusted Publishing needs a recent npm; it also attaches provenance.
- name: Update npm
if: steps.check.outputs.publish == 'true'
run: npm i -g npm@latest

- name: Publish to npm
if: steps.check.outputs.publish == 'true'
env:
NPM_CONFIG_PROVENANCE: 'true'
run: npm publish --access public

- name: Create GitHub release
if: steps.check.outputs.publish == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="v${{ steps.pkg.outputs.version }}"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists."
else
gh release create "$TAG" --target "$GITHUB_SHA" --generate-notes
fi
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ local/
lib/
dist/
.vscode
coverage/
.serena/
*.tgz
4 changes: 1 addition & 3 deletions .gitpod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ tasks:
- name: ts watch
command: tsc -w



vscode:
extensions:
- esbenp.prettier-vscode@6.3.2:13e527ae62035854197198eedd2bc84f
- streetsidesoftware.code-spell-checker@1.10.4:92856cb92ade23ff2764841b46d2fac2
- streetsidesoftware.code-spell-checker@1.10.4:92856cb92ade23ff2764841b46d2fac2
12 changes: 12 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
lib/
dist/
build/
coverage/
node_modules/
examples/
pnpm-lock.yaml
yarn.lock

# Markdown is left alone for now - reformatting the README would bury the
# actual changes in whitespace churn.
*.md
8 changes: 0 additions & 8 deletions __test__/example1.test.js

This file was deleted.

5 changes: 5 additions & 0 deletions __test__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';

// Deliberately no SVG geometry polyfills here. jsdom provides the SVG element
// interfaces without getTotalLength/getBBox, which is the same situation as SSR,
// so running against a bare jsdom is what keeps those code paths honest.
86 changes: 86 additions & 0 deletions __test__/xarrow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react';
import { useRef } from 'react';
import { describe, expect, it } from 'vitest';
import Xarrow from '../src/Xarrow/Xarrow';
import Xwrapper from '../src/Xwrapper';

/**
* Smoke coverage for the render path. These are deliberately shallow — they exist
* so that CI fails loudly if the component stops mounting or starts emitting
* invalid SVG, which is the failure mode behind several open issues.
*/

const TwoBoxes = ({ ...arrowProps }) => (
<Xwrapper>
<div id="box-a" data-testid="box-a">
A
</div>
<div id="box-b" data-testid="box-b">
B
</div>
<Xarrow start="box-a" end="box-b" {...arrowProps} />
</Xwrapper>
);

const getPath = (container: HTMLElement) => container.querySelector('svg path');

describe('Xarrow', () => {
it('mounts and renders an svg path between two elements', () => {
const { container } = render(<TwoBoxes />);

expect(screen.getByTestId('box-a')).toBeInTheDocument();
expect(container.querySelector('svg')).toBeInTheDocument();
expect(getPath(container)).toBeInTheDocument();
});

it('never emits NaN into the path geometry', () => {
const { container } = render(<TwoBoxes />);

const d = getPath(container)?.getAttribute('d');
expect(d).toBeTruthy();
expect(d).not.toMatch(/NaN/);
});

it('never emits NaN into head or tail transforms', () => {
const { container } = render(<TwoBoxes showHead showTail />);

const groups = container.querySelectorAll('svg g');
expect(groups.length).toBeGreaterThan(0);
groups.forEach((g) => {
expect(g.getAttribute('transform') ?? '').not.toMatch(/NaN/);
});
});

it('renders without an arrowhead when showHead is false', () => {
const { container } = render(<TwoBoxes showHead={false} />);

expect(getPath(container)).toBeInTheDocument();
});

// Known bug: with curveness 0 (or path="straight") the head angle is computed as
// Math.atan(absDy / absDx), which is 0/0 when both elements resolve to the same
// point — exactly what happens before layout has measured them. Tracked in
// issues #139, #171 and #192. Unskip this once the guard lands.
it.skip('does not emit NaN when curveness is 0', () => {
const { container } = render(<TwoBoxes curveness={0} />);

expect(getPath(container)?.getAttribute('d')).not.toMatch(/NaN/);
});

it('accepts refs as start and end', () => {
const WithRefs = () => {
const a = useRef<HTMLDivElement>(null);
const b = useRef<HTMLDivElement>(null);
return (
<Xwrapper>
<div ref={a}>A</div>
<div ref={b}>B</div>
<Xarrow start={a} end={b} />
</Xwrapper>
);
};

const { container } = render(<WithRefs />);
expect(container.querySelector('svg path')).toBeInTheDocument();
});
});
Loading
Loading