diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..ad6f18a --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..1d39164 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,123 @@ +# Contributing + +Thanks for your interest in contributing to `@luk4x/list`. + +This project is intentionally small and opinionated. Please read this document carefully before opening an issue or pull request. + +--- + +## Project scope + +This component exists to solve **one specific problem**: + +> Rendering lists in React with correct, stable keys and minimal boilerplate, without hiding React’s behavior. + +Because of that, this project values: + +- **Correctness over convenience** (especially around stable keys) +- **failing loudly over silent fallbacks** +- **Minimal API surface** +- **Strong typing** +- **Clear docs** + +If a proposed change compromises any of these principles, it’s unlikely to be accepted. + +### Proposing changes + +Please do **not** propose changes that: + +- add styling, layout, or visual concerns +- introduce silent fallbacks as a workaround in case of failure +- attempt to “fix” unstable or poorly modeled data +- hide React’s behavior +- expand the API surface beyond its current scope + +Contributions are very welcome when: + +- fix bugs or edge cases related to key handling +- improve type safety without increasing API complexity +- improve documentation clarity or examples +- add tests that validate existing behavior +- clarify error messages or developer feedback + +### Issues and feature requests + +When opening an issue, please include: + +- What you’re trying to do +- Expected behavior +- Actual behavior +- A minimal code example + +--- + +## Project environment + +The project is a **CLI** that prompts for a destination path and copies the `templates/list` component into a user’s codebase. + +Therefore, the changes should be tested through the CLI itself as well, and not just through tests. + +### Folder structure + +``` + +├─ .github/ # GitHub metadata +├─ .husky/ # Git hooks +├─ dist/ # Build output (generated) +├─ src/ # CLI source +├─ templates # Component templates shipped by the CLI +├─ tests/ # Runtime and type tests +├─ index.d.ts # Root type exports for tsd +├─ tsup.config.ts # Build config +└─ vitest.config.ts # Test config + +``` + +### Requirements + +- Node.js >= 18 (recommended via NVM) +- pnpm + +### Tooling and conventions + +This project uses the following tools and conventions: + +- [@changesets/cli](https://www.npmjs.com/package/@changesets/cli) for versioning and releases +- [Conventional Commits](https://www.conventionalcommits.org) for commit messages +- [Vitest](https://vitest.dev/) and [tsd](https://www.npmjs.com/package/tsd) for testing + +### Main scripts + +```bash + +pnpm install # install dependencies +nvm install # install Node.js version from .nvmrc +pnpm run dev # build and run the CLI locally +pnpm run typecheck # run TypeScript checks (runs on every commit) +pnpm run lint # run ESLint (runs on every commit) +pnpm run test:all # run runtime and type tests (runs on every commit) +pnpm run build # build the CLI with tsup (runs on every push) + +``` + +### Recommended CLI local testing workflow + +Because this is a CLI that copies templates into real projects, the most reliable way to test changes is to run it against a fresh test project. + +Create a temporary test project: + +```bash + +mkdir -p /tmp/list-test && cd /tmp/list-test && pnpm init + +``` + +From inside that project, run the CLI directly from your local build (replace the path accordingly): + +```bash + +node /absolute/path/to/list-repo/dist/cli.mjs + +``` + +This simulates how real users interact with the CLI and helps catch issues that unit tests alone may miss (path resolution, prompts, file output, etc.). Changes that affect the CLI or templates should always be tested this way before opening a pull request. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..254d616 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +## Summary + +Briefly explain what this pull request does and why it exists. + +Focus on: + +- what problem is being addressed +- why this change is necessary +- how it aligns with the project’s goals + +Avoid generic statements like “refactor” or “cleanup” without context. + +--- + +## Type of change + +- [ ] Bug fix (fixes incorrect or broken behavior) +- [ ] Type-safety improvement +- [ ] Documentation improvement +- [ ] Internal refactor (no behavior change) +- [ ] Test improvement +- [ ] Other + +--- + +## Design alignment check + +Please confirm the following: + +- [ ] This change does **not** add styling or layout concerns +- [ ] This change does **not** expand the API surface beyond its current scope +- [ ] This change preserves the component’s correctness and fail-fast behavior +- [ ] This change does not hide or alter React’s list behavior +- [ ] CLI behavior remains predictable and non-invasive + +If any of these are unchecked, explain why below. + +--- + +## Details + +Explain the change in more detail if needed. + +Include: + +- edge cases considered +- trade-offs made +- alternative approaches rejected + +--- + +## Checklist + +- [ ] No unnecessary complexity introduced +- [ ] Documentation was updated (if applicable) +- [ ] Tests added/updated (if applicable) and tests pass locally diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd495a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + - name: Lint + run: pnpm run lint + + - name: Tests + run: pnpm run test:all + + - name: Build + run: pnpm run build diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..c48ae69 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm dlx commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..faea098 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run typecheck && pnpm run lint && pnpm run test:all diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..0c958d2 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm run build diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c42da84 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict = true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e200965 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 80 +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1e24258 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "notebook.defaultFormatter": "esbenp.prettier-vscode", + "javascript.updateImportsOnFileMove.enabled": "always", + "typescript.updateImportsOnFileMove.enabled": "always", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll": "explicit" + }, + "eslint.format.enable": true, + "eslint.useESLintClass": true, + "files.eol": "\n" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2614840 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# @luk4x/list + +## 0.1.0 + +### Minor Changes + +- cli creation diff --git a/README.md b/README.md index c9621a3..9eebada 100644 --- a/README.md +++ b/README.md @@ -1 +1,318 @@ -# list \ No newline at end of file +# `@luk4x/list` + +A tiny, type-safe React component focused on improving DX when rendering lists. **No, this [is not another dependency in your `package.json`](#installation)**. + +It removes redundancy, centralizes common list logic, and enforces (or safely infers) stable keys, making list rendering simpler and less error-prone, in a way similar to how other React environments approach list rendering. + +[This component **does not**](#non-goals) style anything or hide React’s rules. + +It only provides a small set of opinionated defaults and fails loudly when correctness cannot be guaranteed, with a [clean mental model to follow](#a-clean-mental-model-to-follow-explicit-identity-in-lists). + +--- + +## Installation + +This component is designed to be **copied into your codebase**, not installed as a runtime dependency. The CLI will prompt for a destination path and copy the component directly into your project. + +| npm | pnpm | yarn | bun | +| ----------------- | ---------------------- | ------------------ | ------------------------ | +| `npx @luk4x/list` | `pnpm dlx @luk4x/list` | `yarn @luk4x/list` | `bunx --bun @luk4x/list` | + +--- + +## Why this exists + +In most React codebases, list rendering ends up looking like some variation of this boilerplate: + +```tsx +{ + items?.length === 0 ? ( +

Empty

+ ) : ( + + ); +} +``` + +With `List`, the same output is simplified to: + +```tsx +

Empty

}> + {item =>
  • {item.title}
  • } +
    +``` + +> "Wait, but where's `key`?" In this example, the key can be safely inferred. See [`keyExtractor`](#keyextractor) for the exact rules. + +--- + +## API Reference + +| Prop | Required | Purpose | Default | +| ------------------------------- | -------------------- | ------------------------------------- | ------------ | +| [`items`](#items) | No | `array` of items to be rendered | — | +| [`children`](#children) | Yes | `function` to render each list item | — | +| [`keyExtractor`](#keyextractor) | Depends on list type | `function` to extract a stable key | — | +| [`renderEmpty`](#renderempty) | No | `function` to render list empty state | `() => null` | +| [`as`](#as) | No | `string` to choose the list element | `'ul'` | + +> All native list element props (`ref`, `className`, `data-*`, `aria-*`...) are supported as well. + +### `items` + +**Type:** `ReadonlyArray | null | undefined` + +The list of items to be rendered. + +When `items` is `null`, `undefined`, or `[]`, `renderEmpty` is rendered instead. + +### `children` + +**Type:** `(item: Item, index: number, array: ReadonlyArray) => ReactNode` + +Render function for each list item. + +Prefer returning a `
  • ` element to preserve correct semantics, or at least an element that has the `role="listitem"` attribute. + +### `keyExtractor` + +**Type:** `(item: Item, index: number) => React.Key` + +Function used to extract a stable key for each list item. + +This prop is **conditionally required**. + +**Optional** (`key` can be inferred) when: + +- `items` are primitive values that are valid as `React.Key` (typically `string` or `number`) +- `items` are objects with a stable `id: React.Key` property + +**Required** (`key` cannot be inferred) when: + +- primitive values are not valid keys +- `id` property is optional, `any`, or `unknown` + +If `keyExtractor` is omitted when required, the component **throws at runtime**. There's no default workaround, such using `index` as fallback. + +**You can always provide `keyExtractor` to explicitly override the inferred behavior if you wish.** + +> The keys must be unique. If your primitive list or `id` property can contain duplicates (which is usually a data-model smell), handle it explicitly via `keyExtractor`. + +### `renderEmpty` + +**Type:** `() => ReactNode` +**Default:** `() => null` + +Render function used when the list has no items. + +Rendered when `items` is `null`, `undefined`, or `[]`. + +The list root (`ul` / `ol`) is **not rendered** when empty. This avoids rendering meaningless list containers and forces layout decisions to be explicit. + +### `as` + +**Type:** `'ul' | 'ol'` +**Default:** `'ul'` + +Choose the semantic list element. + +--- + +## A clean mental model to follow: explicit identity in lists + +When rendering lists in React, one rule simplifies everything: + +> **Every list item must have a stable, unique property that represents its identity.** + +In practice, this works best when **UI data is modeled with an explicit identity.** + +### Example + +Instead of relying on some implicit unique identity property: + +```tsx +const profileTabs = [ + { tab: 'settings-tab', label: 'Settings', Icon: SettingsIcon }, + { tab: 'security-tab', label: 'Security', Icon: ShieldCheckIcon }, + { tab: 'billing-tab', label: 'Billing', Icon: CreditCardIcon }, +]; +``` + +Make the identity explicit: + +```tsx +const profileTabs = [ + { id: 'settings-tab', label: 'Settings', Icon: SettingsIcon }, + { id: 'security-tab', label: 'Security', Icon: ShieldCheckIcon }, + { id: 'billing-tab', label: 'Billing', Icon: CreditCardIcon }, +]; +``` + +Here, `tab` was already the identity. Making it explicit as `id` simply acknowledges that fact, and removes the need for `key` ceremony when using `List`. + +```tsx + + {({ id, label, Icon }) => ( +
  • + +
  • + )} + +``` + +This mental model is not philosophy, it’s a clean way to align: + +- your data +- your UI +- and React’s rules + +
    +Does this mental model hurt readability? +
    + +Only if it’s applied in the wrong place. This isn’t a rule for all data, it’s a UI-boundary rule, meant for data that is mapped into rendered lists. + +At that boundary, you have two valid options: + +- keep a domain-specific field and use `keyExtractor` +- normalize identity to an `id` and remove key ceremony + +Both are correct. Choose the one that reads clearer in your codebase. + +In the `profileTabs` example, `tab` was already acting as identity. Renaming it to `id` doesn’t erase meaning, the context still makes it obvious what the value represents. + +The difference is that now **both you and React can infer the `profileTabs` identity**, without any additional ceremony. + +
    +
    + +--- + +## The core idea about how `List` works + +In short, at runtime, `List` does exactly this: + +- Renders a `