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
5 changes: 5 additions & 0 deletions .changeset/dropdown-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nciocpl/react-components': minor
---

Add `Dropdown` (NCIDS Select) component. Wraps a native `<select>` with USWDS styling and forwards standard form props for use as a controlled or uncontrolled input.
10 changes: 10 additions & 0 deletions .changeset/fix-package-exports-and-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@nciocpl/react-components': minor
---

Fix package distribution issues surfaced during downstream integration:

- **Subpath exports** (`@nciocpl/react-components/core`, `@nciocpl/react-components/ncids`): rollup now uses multi-entry input so the documented subpath bundles (and matching type declarations) are actually emitted.
- **Bundled stylesheet** (`@nciocpl/react-components/styles`): the export now resolves to a real compiled CSS file (USWDS global, `usa-pagination`, `usa-icon`, `usa-collection`). Consumers that don't already compile NCIDS SCSS can `import '@nciocpl/react-components/styles'` and serve `node_modules/@nciocpl/ncids-css/uswds-img` at `/img`. README updated with Vite/Webpack recipes.
- **React peer dependency** raised to `>=17.0.0`. The build emits `react/jsx-runtime` imports (modern JSX transform), which only exist on React ≥ 16.14 — and React 16 has been EOL since 2024. Setting the floor at 17 prevents cryptic `Can't resolve 'react/jsx-runtime'` errors on outdated React installations.
- **`@nciocpl/ncids-css` peer dependency** is now declared (`>=3.0.0`, optional) so the existing `peerDependenciesMeta.optional` entry has effect.
2 changes: 2 additions & 0 deletions .storybook/preview.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@
@forward 'usa-pagination';
@forward 'usa-icon';
@forward 'usa-collection';
@forward 'usa-select';
@forward 'usa-combo-box';
48 changes: 37 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,55 @@ This library provides a canonical set of reusable React components used across N
- **Core Components** -- Framework-agnostic components with self-contained styling (Spinner, ErrorBoundary, Autocomplete, etc.)
- **NCIDS Components** -- Components that integrate with the [NCI Design System](https://designsystem.cancer.gov/) (Button, Accordion, Modal, etc.)

## Requirements

- React `>=17.0.0` and React DOM `>=17.0.0` (the build uses the modern `react/jsx-runtime` transform).
- `@nciocpl/ncids-css` `>=3.0.0` is required to use any NCIDS component (Pager, Collection, etc.). It is declared as an optional peer so apps that only use core components are not forced to install it.

## Installation

```bash
pnpm add @nciocpl/react-components
pnpm add @nciocpl/react-components @nciocpl/ncids-css
```

For NCIDS-styled components, also install the NCIDS CSS package:
## Setup

```bash
pnpm add @nciocpl/ncids-css
NCIDS components rely on USWDS CSS classes. The library ships a pre-bundled stylesheet that compiles the relevant NCIDS/USWDS modules. Import it once at your app entry:

```ts
// e.g. src/main.tsx
import '@nciocpl/react-components/styles';
```

The bundled CSS resolves USWDS sprite/font URLs against `/img`. Configure your app to serve `node_modules/@nciocpl/ncids-css/uswds-img` at that path.

**Vite (vite.config.ts):**

```ts
import { defineConfig } from 'vite';
import path from 'node:path';

export default defineConfig({
publicDir: path.resolve(
__dirname,
'node_modules/@nciocpl/ncids-css/uswds-img'
),
// ...or copy the directory to your existing public/ as `public/img`
});
```

**Webpack / Create React App:** copy `node_modules/@nciocpl/ncids-css/uswds-img` into your `public/img` folder (e.g. via `copy-webpack-plugin` or a postinstall script).

If your app already compiles NCIDS SCSS for non-React UI, skip the bundled stylesheet — the components render with whatever NCIDS rules you have loaded.

## Usage

```tsx
// Import all components
import { Spinner, ErrorBoundary } from '@nciocpl/react-components';

// Import only core components (smaller bundle)
import { Spinner } from '@nciocpl/react-components/core';
// Import from the package root
import { Pager, Collection, CollectionItem } from '@nciocpl/react-components';

// Import only NCIDS components
import { Accordion } from '@nciocpl/react-components/ncids';
// Or scope imports to a category
import { Pager } from '@nciocpl/react-components/ncids';
```

## Development
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"registry": "https://npm.pkg.github.com"
},
"scripts": {
"build": "rollup -c",
"build": "pnpm build:js && pnpm build:styles",
"build:js": "rollup -c",
"build:styles": "sass src/styles/index.scss dist/styles/index.css --load-path=node_modules/@nciocpl/ncids-css/packages --load-path=node_modules/@nciocpl/ncids-css/uswds-packages --no-source-map --style=compressed",
"dev": "storybook dev -p 6006",
"test": "vitest run",
"test:watch": "vitest",
Expand Down Expand Up @@ -70,8 +72,9 @@
},
"homepage": "https://github.com/NCIOCPL/react-app-shared#readme",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
"@nciocpl/ncids-css": ">=3.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
},
"peerDependenciesMeta": {
"@nciocpl/ncids-css": {
Expand Down
37 changes: 11 additions & 26 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import postcssModules from 'postcss-modules';

const external = ['react', 'react-dom', 'react/jsx-runtime'];

const entries = {
index: 'src/index.ts',
'components/core/index': 'src/components/core/index.ts',
'components/ncids/index': 'src/components/ncids/index.ts',
};

function scssModules() {
const cssModulesExports = {};
return scss({
Expand All @@ -27,7 +33,7 @@ function scssModules() {
export default [
// ESM build
{
input: 'src/index.ts',
input: entries,
output: {
dir: 'dist/esm',
format: 'esm',
Expand All @@ -42,6 +48,7 @@ export default [
tsconfig: './tsconfig.build.json',
outDir: 'dist/esm',
declaration: false,
declarationMap: false,
declarationDir: undefined,
}),
scssModules(),
Expand All @@ -50,7 +57,7 @@ export default [
},
// CJS build
{
input: 'src/index.ts',
input: entries,
output: {
dir: 'dist/cjs',
format: 'cjs',
Expand All @@ -65,6 +72,7 @@ export default [
tsconfig: './tsconfig.build.json',
outDir: 'dist/cjs',
declaration: false,
declarationMap: false,
declarationDir: undefined,
}),
scssModules(),
Expand All @@ -73,7 +81,7 @@ export default [
},
// Type declarations
{
input: 'src/index.ts',
input: entries,
output: {
dir: 'dist/types',
format: 'esm',
Expand All @@ -83,27 +91,4 @@ export default [
plugins: [dts()],
external: [/\.scss$/, /\.css$/],
},
// Standalone CSS bundle
{
input: 'src/index.ts',
output: {
dir: 'dist/styles',
format: 'esm',
assetFileNames: '[name][extname]',
},
plugins: [
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.build.json',
outDir: 'dist/styles',
declaration: false,
declarationDir: undefined,
}),
scss({
output: 'dist/styles/index.css',
}),
],
external,
},
];
57 changes: 57 additions & 0 deletions src/components/ncids/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import React from 'react';

import { Dropdown } from './Dropdown';

const meta: Meta<typeof Dropdown> = {
title: 'NCIDS/Dropdown',
component: Dropdown,
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional CSS classes on the list',
},
},
};

export default meta;
type Story = StoryObj<typeof Dropdown>;

export const Default: Story = {
args: {
id: 'default-dropdown',
name: 'default-dropdown',
options: [
{ label: '20', value: 20 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onChange: fn(),
},
};

export const WithResultsPerPageText: Story = {
name: 'With Results Per Page Text',
render: (args) => {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span>Show</span>
<Dropdown {...args} style={{ width: '70px' }} />
<span>results per page</span>
</div>
);
},
args: {
id: 'results-per-page',
name: 'results-per-page',
ariaLabel: 'Select option',
options: [
{ label: '20', value: 20 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onChange: fn(),
},
};
91 changes: 91 additions & 0 deletions src/components/ncids/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React from 'react';
import { cleanup, render, screen } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';

import { Dropdown } from './Dropdown';

describe('<Dropdown />', () => {
afterEach(() => {
cleanup();
});

it('should render options', () => {
render(
<Dropdown
id="test"
name="test"
ariaLabel="Select option"
options={[
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
]}
/>
);

expect(screen.getByRole('option', { name: '20' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '30' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '50' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '100' })).toBeInTheDocument();
});

it('should render a select with usa-select class', () => {
const { container } = render(
<Dropdown
id="test"
name="test"
ariaLabel="Select option"
options={[
{ label: '20', value: '20' },
{ label: '30', value: '30' },
]}
/>
);
const select = container.querySelector('select');
expect(select).toHaveClass('usa-select');
});

it('should call onChange when selection changes', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();

render(
<Dropdown
id="test"
name="test"
onChange={handleChange}
ariaLabel="Select option"
options={[
{ label: 'Twenty', value: 20 },
{ label: 'Thirty', value: 30 },
]}
/>
);

const select = screen.getByRole('combobox');
await user.selectOptions(select, '20');

expect(handleChange).toHaveBeenCalledTimes(1);
});

it('should have no accessibility violations', async () => {
const handleChange = vi.fn();
const { container } = render(
<Dropdown
id="test"
name="test"
ariaLabel="Select option"
onChange={handleChange}
options={[
{ label: 'Twenty', value: 20 },
{ label: 'Thirty', value: 30 },
]}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Loading
Loading