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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ jobs:
bun-version: 1.3.14

- run: bun install --frozen-lockfile
- run: bun run build
- run: bun run test:package
- run: bun run build-storybook
- run: bun run audit
4 changes: 3 additions & 1 deletion .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Preview } from '@storybook/react-vite';
import 'spectre.css';
import 'spectre.css/dist/spectre.min.css';
import 'spectre.css/dist/spectre-icons.min.css';
import 'spectre.css/dist/spectre-exp.min.css';

const preview: Preview = {
parameters: {
Expand Down
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,39 @@

Spectre.css and React integration

## Usage
## Requirements

- React and React DOM 18 or 19
- Spectre.css 0.5.9 styles, loaded by the consuming application

Installation:
## Installation

```sh
npm install spectre-react-lib
npm install spectre-react-lib spectre.css@0.5.9
```

`spectre.css` is an explicit external stylesheet dependency rather than a package peer dependency. This allows applications to use the npm package, a custom Sass build, or a CDN without package-manager conflicts. Version 0.5.9 is the currently tested CSS contract.

## Stylesheets

This library does not inject global CSS. Import the layers your application uses, in this order:

```js
// Required for stable/core components
import 'spectre.css/dist/spectre.min.css';

// Optional for Spectre icons
import 'spectre.css/dist/spectre-icons.min.css';

// Required in addition to core for experimental components
import 'spectre.css/dist/spectre-exp.min.css';
```

Importing components:
Applications using customized Sass or CDN styles should load the equivalent layers in the same order.

## Usage

Stable components are exported from the package root:

```js
import {
Expand All @@ -32,12 +56,19 @@ import {
Grid,
Divider,
Loading,
Parallax,
ControlledTab,
EmptyState,
} from 'spectre-react-lib';
```

Experimental components use a separate entrypoint:

```js
import { Parallax } from 'spectre-react-lib/experimental';
```

The existing root `Parallax` export remains available for backwards compatibility. The `spectre-react-lib/icons` boundary is reserved for the typed Icon component planned in the component coverage roadmap.

## Storybook Examples

[Click here](http://spectre-react-lib.philvr.ca) to see the storybook examples.
Expand All @@ -51,7 +82,7 @@ bun install
bun run ci
```

The current dev stack targets Bun 1.4+ and Node.js 20.19+.
The supported CI and package-manager baseline is Bun 1.3.14, and local development may use the Bun 1.4 canary. Node.js 20.19+ is required.

## Automated Releases

Expand Down
Binary file modified bun.lockb
Binary file not shown.
31 changes: 18 additions & 13 deletions docs/plans/spectre-component-coverage-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1002,19 +1002,24 @@ Experimental APIs remain semver-governed, but their docs should state that they
| Upstream Spectre is inactive. | Pin v0.5.9 and require an explicit compatibility review for any fork/version change. |
| The ignored clone leaks into published artifacts. | Test tarball contents and keep package `files` restricted to `dist`. |

## 17. Open decisions

These decisions must be recorded in the Phase 0 PR before implementation spreads their consequences:

1. **React support:** Drop React 17 as recommended, or implement and fixture-test an SSR-safe fallback ID strategy.
2. **CSS package relationship:** Exact documented external dependency only, or optional exact peer dependency.
3. **Browser/a11y tooling:** Exact Playwright/axe/Storybook integration and CI cost.
4. **Polymorphic scope:** Final list of wrappers allowed to use `as`.
5. **Modal implementation:** Internal implementation versus a small headless dependency. Any dependency must be justified by bundle size, peer compatibility, SSR, and API stability.
6. **Calendar naming/scope:** `Calendar` as a presentational shell versus deferring the name until it is a complete date grid.
7. **Bar.Slider range API:** One component with one/two values versus separate single/range roots.
8. **Development-time limit handling:** Warning versus throwing for compiled Filter/Carousel/Viewer360 limits.
9. **Stable individual subpaths:** Add after category exports, or defer until `1.0`.
## 17. Decisions

### 17.1 Closed in Phase 0

1. **React support:** Support React and React DOM 18/19. React 17 is removed from the peer range because the existing `ControlledTab` contract uses React's hydration-safe `useId`.
2. **CSS package relationship:** Pin `spectre.css` 0.5.9 as an exact development dependency and document it as an exact external consumer dependency. Do not add it as a peer dependency, so custom Sass and CDN consumers do not encounter package-manager conflicts.

### 17.2 Open decisions

These decisions must be recorded in Phase 0 before implementation spreads their consequences:

1. **Browser/a11y tooling:** Exact Playwright/axe/Storybook integration and CI cost.
2. **Polymorphic scope:** Final list of wrappers allowed to use `as`.
3. **Modal implementation:** Internal implementation versus a small headless dependency. Any dependency must be justified by bundle size, peer compatibility, SSR, and API stability.
4. **Calendar naming/scope:** `Calendar` as a presentational shell versus deferring the name until it is a complete date grid.
5. **Bar.Slider range API:** One component with one/two values versus separate single/range roots.
6. **Development-time limit handling:** Warning versus throwing for compiled Filter/Carousel/Viewer360 limits.
7. **Stable individual subpaths:** Add after category exports, or defer until `1.0`.

## 18. Coverage checklist by phase

Expand Down
23 changes: 18 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@
"build-storybook": "storybook build",
"tsc": "tsc --noEmit",
"test": "bun test --coverage",
"test:package": "bun run build && node tests/package/esm.mjs && node tests/package/commonjs.cjs && node tests/package/react-compatibility.mjs && tsc --project tests/package/tsconfig.json",
"test:watch": "bun test --watch",
"lint": "vp lint --config .oxlintrc.json src .storybook --deny-warnings",
"lint": "vp lint --config .oxlintrc.json src .storybook tests --deny-warnings",
"audit": "bun audit --audit-level moderate",
"prepare": "lefthook install",
"ci": "bun run format:check && bun run lint && bun run tsc && bun run test && bun run build && bun run build-storybook && bun run audit",
"ci": "bun run format:check && bun run lint && bun run tsc && bun run test && bun run test:package && bun run build-storybook && bun run audit",
"release:help": "bunx release-please --help",
"release:beta": "bunx release-please release-pr --prerelease-type beta --repo-url FIL1994/spectre-react-lib --token $GITHUB_TOKEN",
"release:bootstrap": "bunx release-please bootstrap --prerelease --repo-url FIL1994/spectre-react-lib --token $GITHUB_TOKEN --initial-version 0.4.5 --release-type node --bump-minor-pre-major --bump-patch-for-minor-pre-major"
Expand All @@ -45,6 +46,18 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./icons": {
"types": "./dist/icons.d.ts",
"import": "./dist/icons.js",
"require": "./dist/icons.cjs",
"default": "./dist/icons.js"
},
"./experimental": {
"types": "./dist/experimental.d.ts",
"import": "./dist/experimental.js",
"require": "./dist/experimental.cjs",
"default": "./dist/experimental.js"
}
},
"sideEffects": false,
Expand All @@ -71,7 +84,7 @@
"lefthook": "^2.1.10",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"spectre.css": "^0.5.9",
"spectre.css": "0.5.9",
"storybook": "^10.5.0",
"tiny-invariant": "^1.3.3",
"ts-essentials": "^10.2.1",
Expand All @@ -80,8 +93,8 @@
"vite-plus": "^0.2.4"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"packageManager": "bun@1.3.14",
"publishConfig": {
Expand Down
1 change: 1 addition & 0 deletions src/experimental.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Parallax, type ParallaxProps } from './experimentals/Parallax';
7 changes: 7 additions & 0 deletions src/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Public boundary for optional icon components.
*
* The typed Icon component will be added here without growing the stable root
* entrypoint. Keep this module side-effect free so importing it never loads CSS.
*/
export {};
13 changes: 13 additions & 0 deletions tests/package/commonjs.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const assert = require('node:assert/strict');

const root = require('spectre-react-lib');
const icons = require('spectre-react-lib/icons');
const experimental = require('spectre-react-lib/experimental');

assert.ok('Button' in root, 'the CommonJS root entrypoint should export Button');
assert.ok('Parallax' in root, 'the CommonJS root should retain the legacy Parallax export');
assert.equal(typeof icons, 'object', 'the CommonJS icons boundary should be importable');
assert.ok(
'Parallax' in experimental,
'the CommonJS experimental entrypoint should export Parallax'
);
28 changes: 28 additions & 0 deletions tests/package/esm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

const root = await import('spectre-react-lib');
const icons = await import('spectre-react-lib/icons');
const experimental = await import('spectre-react-lib/experimental');

assert.ok('Button' in root, 'the ESM root entrypoint should export Button');
assert.ok('Parallax' in root, 'the ESM root should retain the legacy Parallax export');
assert.equal(typeof icons, 'object', 'the ESM icons boundary should be importable');
assert.ok('Parallax' in experimental, 'the ESM experimental entrypoint should export Parallax');

const packageDirectory = fileURLToPath(new URL('../..', import.meta.url));
const declarations = ['index.d.ts', 'icons.d.ts', 'experimental.d.ts'];

for (const declaration of declarations) {
assert.ok(
existsSync(`${packageDirectory}/dist/${declaration}`),
`dist/${declaration} should be emitted`
);
}

assert.deepEqual(
readdirSync(`${packageDirectory}/dist`).filter((file) => file.endsWith('.css')),
[],
'package entrypoints should not emit or inject CSS'
);
13 changes: 13 additions & 0 deletions tests/package/imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Button, type ButtonProps, Parallax as LegacyParallax } from 'spectre-react-lib';
import * as icons from 'spectre-react-lib/icons';
import { Parallax, type ParallaxProps } from 'spectre-react-lib/experimental';

const buttonProps: ButtonProps = { type: 'button' };
const parallaxProps: ParallaxProps = { title: 'Package fixture' };

void Button;
void LegacyParallax;
void icons;
void Parallax;
void buttonProps;
void parallaxProps;
69 changes: 69 additions & 0 deletions tests/package/react-compatibility.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const packageDirectory = fileURLToPath(new URL('../..', import.meta.url));
const fixtureSource = fileURLToPath(new URL('./react-consumer.mjs', import.meta.url));
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'spectre-react-lib-'));

/**
* @param {string} command
* @param {string[]} arguments_
* @param {string} cwd
*/
const run = (command, arguments_, cwd) => {
const result = spawnSync(command, arguments_, {
cwd,
encoding: 'utf8',
env: { ...process.env, CI: 'true' },
});

assert.equal(
result.status,
0,
[`${command} ${arguments_.join(' ')} failed in ${cwd}`, result.stdout, result.stderr]
.filter(Boolean)
.join('\n')
);
};

try {
run(
'bun',
['pm', 'pack', '--destination', temporaryDirectory, '--ignore-scripts'],
packageDirectory
);

const tarballName = readdirSync(temporaryDirectory).find((file) => file.endsWith('.tgz'));
assert.ok(tarballName, 'bun pm pack should produce a tarball');
const tarball = join(temporaryDirectory, tarballName);

for (const version of ['18.3.1', '19.2.7']) {
const fixtureDirectory = join(temporaryDirectory, `react-${version}`);
const fixturePackage = {
name: `spectre-react-lib-react-${version}-fixture`,
private: true,
type: 'module',
dependencies: {
react: version,
'react-dom': version,
'spectre-react-lib': `file:${tarball}`,
},
};

mkdirSync(fixtureDirectory, { recursive: true });
writeFileSync(
join(fixtureDirectory, 'package.json'),
`${JSON.stringify(fixturePackage, null, 2)}\n`
);
copyFileSync(fixtureSource, join(fixtureDirectory, 'react-consumer.mjs'));

run('bun', ['install', '--ignore-scripts', '--no-progress'], fixtureDirectory);
run('node', ['react-consumer.mjs', version.split('.')[0]], fixtureDirectory);
}
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
50 changes: 50 additions & 0 deletions tests/package/react-consumer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { ControlledTab } from 'spectre-react-lib';
import * as icons from 'spectre-react-lib/icons';
import { Parallax } from 'spectre-react-lib/experimental';

const expectedMajor = process.argv[2];
const require = createRequire(import.meta.url);
const commonJsRoot = require('spectre-react-lib');
const commonJsIcons = require('spectre-react-lib/icons');
const commonJsExperimental = require('spectre-react-lib/experimental');

assert.equal(
React.version.split('.')[0],
expectedMajor,
`fixture should run with React ${expectedMajor}`
);
assert.equal(typeof icons, 'object', 'the icons boundary should import without browser globals');
assert.ok('Button' in commonJsRoot, 'the CommonJS root should load in the React fixture');
assert.equal(
typeof commonJsIcons,
'object',
'the CommonJS icons boundary should load in the React fixture'
);
assert.ok(
'Parallax' in commonJsExperimental,
'the CommonJS experimental boundary should load in the React fixture'
);

const tabs = React.createElement(ControlledTab, {
options: [
{
label: 'First tab',
value: 'first',
render: () => React.createElement('p', null, 'First panel'),
},
],
});
const tabsHtml = renderToString(tabs);

assert.match(tabsHtml, /role="tablist"/, 'ControlledTab should server render');
assert.match(tabsHtml, /aria-controls=/, 'useId relationships should server render');

const parallaxHtml = renderToString(
React.createElement(Parallax, { title: 'Experimental fixture' }, 'Back layer')
);

assert.match(parallaxHtml, /class="parallax"/, 'the experimental entrypoint should server render');
7 changes: 7 additions & 0 deletions tests/package/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": ["./imports.ts"],
"compilerOptions": {
"noEmit": true
}
}
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"extends": "./tsconfig.json",
"include": ["src/index.ts"],
"include": ["src/index.ts", "src/icons.ts", "src/experimental.ts"],
"exclude": ["**/*.stories.*", "**/*.test.*"],
"compilerOptions": {
"noEmit": false,
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["."],
"exclude": ["dist", "build", "node_modules", "storybook-static"],
"exclude": ["dist", "build", "node_modules", "spectre", "storybook-static"],
"compilerOptions": {
// Enable top-level await, and other modern ESM features.
"target": "ESNext",
Expand Down
Loading
Loading