diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index ca3165e..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,59 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - env: { - browser: true, - }, - settings: { - react: { - version: 'detect', - }, - }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react/recommended', - 'plugin:prettier/recommended', - "plugin:react/jsx-runtime", - ], - plugins: ['react-hooks'], - overrides: [ - { - files: ['**/*.test.tsx', '**/*.test.ts'], - env: { - jest: true, // now **/*.test.js files' env has both es6 *and* jest - }, - // Can't extend in overrides: https://github.com/eslint/eslint/issues/8813 - // 'extends': ['plugin:jest/recommended'] - plugins: ['jest'], - rules: { - 'jest/no-disabled-tests': 'warn', - 'jest/no-focused-tests': 'error', - 'jest/no-identical-title': 'error', - 'jest/prefer-to-have-length': 'warn', - 'jest/valid-expect': 'error', - '@typescript-eslint/ban-ts-ignore': 'off', - '@typescript-eslint/no-empty-function': 'off', - 'no-restricted-imports': 'off', - }, - }, - { - files: ['src/stories/*.tsx'], - env: { - node: true, - }, - rules: { - // 'react/display-name': 'off', - '@typescript-eslint/no-explicit-any': 'off' - }, - }, - ], - rules: { - '@typescript-eslint/explicit-module-boundary-types': 0, - '@typescript-eslint/no-unused-vars': [ - 'error', - { ignoreRestSiblings: true }, - ], - 'react/prop-types': 0, - 'no-restricted-imports': ['error', '@mui/material', '@mui/x-date-pickers'], - }, -}; diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..629ead1 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: check (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [24, 26] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run check + - name: Build Storybook + if: matrix.node == 24 + run: npm run build-storybook + + react-18: + name: packages on React 18 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - run: npm ci + # The workspace develops on React 19; the packages also support 18. + - name: Swap in React 18 + run: npm install --no-save --legacy-peer-deps react@18 react-dom@18 @types/react@18 @types/react-dom@18 + - name: Typecheck the packages + run: npx tsc -b packages/mui packages/x-date-pickers packages/x-date-pickers-pro + - run: npm test diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml deleted file mode 100644 index e237a8d..0000000 --- a/.github/workflows/linting.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: Linting -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: build - run: | - npm ci - npm run lint - env: - CI: true diff --git a/.github/workflows/mui-x-date-pickers-pro.yaml b/.github/workflows/mui-x-date-pickers-pro.yaml deleted file mode 100644 index 0932048..0000000 --- a/.github/workflows/mui-x-date-pickers-pro.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: Build mui-x-date-pickers-pro -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x] - - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - name: build - run: | - npm run typecheck - # npm run test - npm run build - working-directory: packages/x-date-pickers-pro - env: - CI: true diff --git a/.github/workflows/mui-x-date-pickers.yaml b/.github/workflows/mui-x-date-pickers.yaml deleted file mode 100644 index 5d212f7..0000000 --- a/.github/workflows/mui-x-date-pickers.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: Build mui-x-date-pickers -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x] - - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - name: build - run: | - npm run typecheck - # npm run test - npm run build - working-directory: packages/x-date-pickers - env: - CI: true diff --git a/.github/workflows/mui.yaml b/.github/workflows/mui.yaml deleted file mode 100644 index b6bb0c7..0000000 --- a/.github/workflows/mui.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: Build mui -on: - push: - branches: - - main - pull_request: - branches: - - '**' - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x] - - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - name: build - run: | - npm run typecheck - # npm run test - npm run build - working-directory: packages/mui - env: - CI: true diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml index 2f6b517..9ce0bcf 100644 --- a/.github/workflows/pages.yaml +++ b/.github/workflows/pages.yaml @@ -1,22 +1,35 @@ name: Deploy to GitHub Pages + on: push: - branches: - - main + branches: [main] + +permissions: + contents: write + +concurrency: + group: pages + cancel-in-progress: false + jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v1 - - - - name: Build Pages + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + docs/package-lock.json + - name: Build Storybook run: | - # Build Storybook npm ci npm run build-storybook - cd docs + - name: Build the docs and deploy them with Storybook + working-directory: docs + run: | npm ci git config --global user.email "actions@github.com" git config --global user.name "GitHub Pages Action" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..934302f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,47 @@ +name: Release + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write # pushes a @ tag for each version it publishes + id-token: write # required for npm trusted publishing (OIDC) + +# One release at a time, and never cancelled halfway through publishing. +concurrency: + group: release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # The tags, so versions released before are not tagged again. + fetch-depth: 0 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + registry-url: https://registry.npmjs.org + # Trusted publishing (OIDC) requires npm >= 11.5.1 + - name: Update npm + run: npm install -g npm@latest + - run: npm ci + - run: npm run check + # No NPM_TOKEN: npm authenticates through OIDC with the trusted publisher configured for this + # workflow on npmjs.com, and adds provenance itself. + - name: Publish the versions npm doesn't have yet, in dependency order + run: npm run release:publish + - name: Tag each published version + run: | + for dir in packages/*/; do + tag=$(node -p "const p = require('./${dir}package.json'); p.name + '@' + p.version") + if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + git tag "$tag" + git push origin "refs/tags/$tag" + fi + done diff --git a/.github/workflows/storybook.yaml b/.github/workflows/storybook.yaml deleted file mode 100644 index e4f9532..0000000 --- a/.github/workflows/storybook.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: Build Storybook -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: build - run: | - npm ci - npm run build-storybook - env: - CI: true diff --git a/.gitignore b/.gitignore index 039cfbe..f8e7474 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,12 @@ dist yarn-error.log .idea yarn.lock +*.tsbuildinfo storybook-static/ coverage lerna-debug.log # IDE Files -*.code-workspace \ No newline at end of file +*.code-workspace +.nx diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index db666f3..0000000 --- a/.storybook/main.js +++ /dev/null @@ -1,24 +0,0 @@ -/** @type { import('@storybook/react-vite').StorybookConfig } */ -const config = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], - staticDirs: ["../public"], - addons: [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-interactions", - ], - framework: { - name: "@storybook/react-vite", - options: {}, - }, - swc: () => ({ - jsc: { - transform: { - react: { - runtime: 'automatic' - } - } - } - }), -}; -export default config; diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000..bc124fe --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,44 @@ +import {fileURLToPath} from 'node:url'; +import type {StorybookConfig} from '@storybook/react-vite'; + +// MUI props that make useful controls. Every other prop MUI declares stays out of the parsed prop +// types, so the Docs tables list ours. +const muiControlProps = new Set([ + 'ampm', + 'calendars', + 'closeOnSelect', + 'color', + 'disableClearable', + 'disableFuture', + 'disablePast', + 'format', + 'limitTags', + 'minRows', + 'multiline', + 'placeholder', + 'readOnly', + 'size', + 'variant', +]); + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.tsx'], + addons: ['@storybook/addon-docs'], + framework: {name: '@storybook/react-vite', options: {}}, + typescript: { + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + // The root tsconfig covers the stories only; the components live in the packages. + tsconfigPath: fileURLToPath( + new URL('tsconfig.docgen.json', import.meta.url), + ), + shouldExtractLiteralValuesFromEnum: true, + shouldRemoveUndefinedFromOptional: true, + propFilter: (prop) => + !(prop.parent?.fileName.includes('node_modules') ?? false) + || muiControlProps.has(prop.name), + }, + }, +}; + +export default config; diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html deleted file mode 100644 index 05da1e9..0000000 --- a/.storybook/preview-head.html +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js deleted file mode 100644 index 73f2988..0000000 --- a/.storybook/preview.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type { import('@storybook/react').Preview } */ -const preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, - }, - // tags: ["autodocs"] -}; - -export default preview; \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..942607b --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,24 @@ +import CssBaseline from '@mui/material/CssBaseline'; +import {createTheme, ThemeProvider} from '@mui/material/styles'; +import {AdapterLuxon} from '@mui/x-date-pickers/AdapterLuxon'; +import {LocalizationProvider} from '@mui/x-date-pickers/LocalizationProvider'; +import type {Preview} from '@storybook/react-vite'; + +const theme = createTheme(); + +const preview: Preview = { + tags: ['autodocs'], + parameters: {layout: 'padded'}, + decorators: [ + (Story) => ( + + + + + + + ), + ], +}; + +export default preview; diff --git a/.storybook/tsconfig.docgen.json b/.storybook/tsconfig.docgen.json new file mode 100644 index 0000000..28c56c5 --- /dev/null +++ b/.storybook/tsconfig.docgen.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["../packages/*/src", "../src"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71863af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +## 0.1.0 + +All three packages: `@stackworx/react-hook-form-mui`, `@stackworx/react-hook-form-mui-x-date-pickers` +and `@stackworx/react-hook-form-mui-x-date-pickers-pro`. + +Upgrading from 0.0.x? [MIGRATION.md](MIGRATION.md) walks through the changes with before and after code. + +### Breaking changes + +#### Peers and packaging + +- Peers are now `react ^18.0.0 || ^19.0.0`, `react-hook-form >=7.62.0 <8` and `@mui/material ^9.0.0`. The + pickers also need `@mui/x-date-pickers ^9.0.0`, and the Pro package `@mui/x-date-pickers-pro ^9.0.0`. + `@base-ui/react` is an optional peer, needed only for `NumberField`. +- ESM only, through an `exports` map. `NumberField` has its own entry point, + `@stackworx/react-hook-form-mui/number-field`, so apps without `@base-ui/react` never resolve it. +- The tarballs contain `dist` only (no `src`, tsconfigs or `*.tsbuildinfo`). +- The Pro package depends on `@stackworx/react-hook-form-mui-x-date-pickers`. +- Both picker packages also need `@stackworx/react-hook-form-mui` (a peer), which holds the settings + every field in a form shares. + +#### All components + +- Only `name`, `control`, `rules`, `defaultValue`, `shouldUnregister` and `disabled` go to + `useController`; they no longer leak onto MUI or the DOM. `disabled` now reaches React Hook Form, + so a disabled field is not validated. +- Your handlers are `handleChange` and `handleBlur`, on every component; MUI's `onChange` and + `onBlur` are no longer accepted. `handleChange` runs after the form stores each change, and takes + the MUI component's `onChange` arguments (the option groups pass the typed option value, and + `NumberField` Base UI's `(value, eventDetails)`). `handleBlur` runs after the form's blur handler. + `suppressFormChange` leaves storing to `handleChange`, which can refuse a change. + - In 0.0.x, `TextField`, `Select`, `Checkbox`, `Switch`, `RadioGroup` and `CheckboxGroup` accepted + `onChange` and `onBlur` but never called them. `Autocomplete`'s and the pickers' `onChange` + replaced the form's, and so did `Autocomplete`'s `onBlur`; the pickers' `onBlur` was never + called. `ToggleButtonGroup` took no `onChange`. +- `helperText` shows the error message, else your `helperText`. +- An `undefined` value renders as empty instead of switching from uncontrolled to controlled. + +#### Core + +- **Checkbox** and **Switch** render their own label and helper text (`label` is required). + `CheckboxWithLabel` is removed. +- **CheckboxGroup** is a single component that takes `options` and stores the array of checked + values. It used to be one `CheckboxGroup` per option with a `value` prop. +- **RadioGroup** takes `options` instead of `Radio` children and stores typed values (numbers and + booleans survive). The `Radio` export is removed. +- **ToggleButtonGroup** takes `options` instead of `ToggleButton` children. `exclusive` defaults to + `true`, and `enforceValue` keeps the current selection. +- **Select** takes `options` instead of `MenuItem` children and `multiple` instead of + `SelectProps.multiple`. It stores the option's typed value, and `null` when empty. +- **Autocomplete** needs `label` and renders its own input. Options follow MUI's conventions: strings + and numbers need neither `getOptionKey` nor `getOptionLabel`, and an object's label defaults to its + `label`. Object options need `getOptionKey`, and a stored option is matched by it rather than by + reference, so default values and refetched options stay selected. It still stores the selected + option; `getOptionValue` stores something else instead, such as the option's id, and the field's + type decides which is allowed. +- The 0.0.x components passed `inputRef`, which MUI 9 removed; refs now go through `slotProps.input`. + +#### Date and time pickers + +- The `TDate` and `TEnableAccessibleFieldDOMStructure` generics are gone (MUI X 9), and so is the + use of `@mui/x-date-pickers/internals`. +- Validation messages changed. Each MUI X validation code has a short English default (for + example `minDate` → "Date is too early") and dates are no longer interpolated into the text. The + new `messages` prop overrides them per field. Errors appear on the change that causes them instead + of one change later. +- A function-form `rules.validate` now runs; it used to be dropped. +- The `ErrorContext` exports are removed. +- `DateRangePicker` rendered itself (infinite recursion) and never received its value. Both are + fixed. + +### New + +- `TextField` `transform`; `NumberField` (Base UI recipe, `number | null`). +- `AsyncAutocomplete`: server-backed options through an `OptionsSource`, with a load-more footer and + the same option conventions and choice of stored value as `Autocomplete`. Selected options keep their labels across pages + and searches. +- `FormErrorMessagesProvider` for default and translated rule messages. +- An empty helper line keeps its space, as `helperText=' '` did, so an error appearing doesn't move + the form. `reserveHelperText={false}` turns that off for a field, and `HelperTextProvider` for a + form or section. +- Pickers: `DateField`, `TimeField` and `DateTimeField`, `transform` on every picker, and the + `usePickerController` / `pickerValueProps` / `pickerTextFieldSlotProps` / `pickerHelperText` + building blocks. +- Pro: `DateTimeRangePicker` and `SingleInputDateRangeField`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c53f09 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2026 Stackworx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..96e0f6e --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,192 @@ +# Migrating + +## 0.0.x to 0.1.0 + +0.1.0 rebuilds every component for MUI 9 and MUI X 9. This guide covers the changes most forms need, with +before and after code. [CHANGELOG.md](CHANGELOG.md) lists every breaking change. + +### Update the packages + +0.1.0 needs React 18 or 19, MUI 9, React Hook Form 7.62 or later, and MUI X 9 for the pickers. Follow MUI's +own upgrade guides first, then update these packages. They are ESM only. + +```bash +npm install @stackworx/react-hook-form-mui@^0.1.0 react-hook-form@^7.62.0 +# The picker packages now need the core package above as well: +npm install @stackworx/react-hook-form-mui-x-date-pickers@^0.1.0 +npm install @stackworx/react-hook-form-mui-x-date-pickers-pro@^0.1.0 +``` + +### Your own handlers: `handleChange` and `handleBlur` + +MUI's `onChange` and `onBlur` are no longer accepted. In 0.0.x they did different things per component. Most +components ignored them. On `Autocomplete` and the pickers, your `onChange` replaced the form's, so a value was +only stored if you stored it. + +`handleChange` runs after the form stores the value, and `handleBlur` after the form marks the field touched: + +```tsx +// Before: your onChange replaced the form's. + { + setValue('country', country); + setValue('province', null); + }} + renderInput={(params) => } +/>; + +// After: the form stores the value, and handleChange reacts to it. + country.code} + getOptionLabel={(country) => country.name} + handleChange={() => setValue('province', null)} +/>; +``` + +To keep the 0.0.x behaviour, where your handler decides what is stored, add `suppressFormChange` and store the +value with `setValue`. A change you don't store is ignored. + +`handleChange` takes the MUI component's `onChange` arguments. `RadioGroup`, `CheckboxGroup` and +`ToggleButtonGroup` pass the typed option value, and `NumberField` passes Base UI's `(value, eventDetails)`. + +### Checkbox and Switch render their own label + +`CheckboxWithLabel` is gone. `Checkbox` and `Switch` take a required `label`, and show helper and error text. + +```tsx +// Before +; +} + label='Email me' +/>; + +// After +; +; +``` + +### Option components take `options` + +`Select`, `RadioGroup` and `ToggleButtonGroup` take an `options` array instead of `MenuItem`, `Radio` or +`ToggleButton` children. They store the option's typed value, so numbers and booleans stay numbers and +booleans. `CheckboxGroup` is now one component for the whole group, instead of one per option. The `Radio` +export is gone. + +```tsx +// Before + + } + label='Small' + /> + } + label='Large' + /> +; + +} + label='Monday' +/>; +} + label='Tuesday' +/>; + +// After +; + +; +``` + +- **Select** takes `multiple` instead of `SelectProps={{multiple: true}}`, and stores `null` when nothing is + selected. +- **ToggleButtonGroup** is now exclusive by default. 0.0.x followed MUI's default, which isn't, so pass + `exclusive={false}` to keep storing an array. `enforceValue` stops a click from clearing the selection. + +### Autocomplete renders its own input + +Pass `label` instead of `renderInput`. The form still stores the selected option, but now matches it by +`getOptionKey` rather than by reference, so default values and refetched options stay selected. Object +options need `getOptionKey` in place of `isOptionEqualToValue`. As in MUI, strings and numbers need neither +`getOptionKey` nor `getOptionLabel`, and an object's label defaults to its `label`. + +```tsx +// Before + location.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => } +/>; + +// After + location.id} + getOptionLabel={(location) => location.name} +/>; +``` + +To store the id instead of the option, add `getOptionValue={(location) => location.id}`. + +### Date and time pickers + +- **Drop the `TDate` and `TEnableAccessibleFieldDOMStructure` type arguments** if you passed them. MUI X no + longer has them. +- **Validation messages are shorter and no longer include dates.** `minDate`, for example, shows "Date is too + early". Set your own per field with `messages`: + + ```tsx + ; + ``` +- **An error appears on the change that causes it**, not one change later. +- **`helperText` is a prop of the picker.** `slotProps.textField.helperText` still works. + +### Behaviour to check + +- **Disabled fields reach React Hook Form.** A disabled field isn't validated, and its value is left out of + what `handleSubmit` receives. If you need the value, make the field read-only instead of disabled. +- **Every field keeps an empty helper line**, as 0.0.x's `TextField` and `Select` did, so an error appearing + doesn't move the form. `reserveHelperText={false}` turns this off for one field, and + `` for a form or section. +- **An `undefined` value renders as empty**, instead of warning about switching from uncontrolled to + controlled. +- **A rule without a message**, such as `rules={{required: true}}`, shows a default message, which + `FormErrorMessagesProvider` can replace. The pickers don't read the provider yet. diff --git a/NOTES.md b/NOTES.md index 1ec4e4e..a062ad6 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,4 +1,4 @@ # Notes - Required - for checkbox this trigger native required -- How does rules required interactice with validation libraries \ No newline at end of file +- How does rules required interactice with validation libraries diff --git a/README.md b/README.md index 096d0ca..68451e7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,382 @@ -# Read Me +# React Hook Form × MUI -TODO +[React Hook Form](https://react-hook-form.com) bindings for [MUI](https://mui.com) 9 and MUI X 9. +Each component is the MUI component you already know, wired to `useController`: the value, the +error text, `setFocus` and `disabled` all behave the same way. -## Notes +| Package | Components | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@stackworx/react-hook-form-mui` | `TextField`, `NumberField`, `Select`, `Checkbox`, `Switch`, `CheckboxGroup`, `RadioGroup`, `ToggleButtonGroup`, `Autocomplete`, `AsyncAutocomplete` | +| `@stackworx/react-hook-form-mui-x-date-pickers` | `DatePicker`, `TimePicker`, `DateTimePicker`, `DateField`, `TimeField`, `DateTimeField` | +| `@stackworx/react-hook-form-mui-x-date-pickers-pro` | `DateRangePicker`, `DateTimeRangePicker`, `SingleInputDateRangeField` | - - https://react-hook-form.com/advanced-usage#FormProviderPerformance \ No newline at end of file +## Install + +The packages are ESM only and need React 18 or 19, MUI 9 and react-hook-form 7.62 or later. Upgrading from +0.0.x? See [MIGRATION.md](MIGRATION.md). + +```bash +npm install @stackworx/react-hook-form-mui react-hook-form @mui/material @emotion/react @emotion/styled +# NumberField only: +npm install @base-ui/react + +# Date and time pickers (with the core package above; bring any MUI X adapter, e.g. Luxon): +npm install @stackworx/react-hook-form-mui-x-date-pickers @mui/x-date-pickers luxon + +# Range pickers (MUI X Pro licence): +npm install @stackworx/react-hook-form-mui-x-date-pickers-pro @mui/x-date-pickers-pro +``` + +## How every component behaves + +- `name`, `control`, `rules`, `defaultValue`, `shouldUnregister` and `disabled` go to + `useController`; everything else goes to the MUI component. `disabled` also reaches React Hook + Form, so a disabled field is not validated and is left out of the submitted values. +- The helper text shows the rule's message when there is an error, and your `helperText` otherwise. + A rule without a message (`rules={{required: true}}`) falls back to `FormErrorMessagesProvider` + (English defaults), then to the error type. +- An empty helper line keeps its space, so an error appearing doesn't move the fields below it. + `reserveHelperText={false}` turns that off for one field, and `HelperTextProvider` for a form or + section; a field's own setting wins. +- Your handlers are `handleChange` and `handleBlur`; MUI's own `onChange` and `onBlur` aren't + accepted. `handleChange` runs after the form stores each change, and takes the MUI component's + `onChange` arguments: the option groups pass the typed option value, and `NumberField` Base UI's + `(value, eventDetails)`. `handleBlur` runs after the form's blur handler marks the field touched. +- With `suppressFormChange`, the form stores nothing: `handleChange` stores the changes it keeps with + `setValue`, and ignores the rest. Autocomplete and the pickers then show the stored value again. + `NumberField` shows the text until it loses focus, and a date field keeps the text that was typed. +- `form.setFocus(name)` focuses the input. +- An `undefined` value renders as empty (`''`, `null` or `[]`), so there are no + uncontrolled-to-controlled warnings. + +```tsx +// Only products that are available can be picked. + product.id} + getOptionLabel={(product) => product.name} + suppressFormChange + handleChange={(_event, product) => { + if (product && !isAvailable(product)) return; + setValue('product', product, {shouldDirty: true, shouldValidate: true}); + }} +/>; +``` + +```tsx +import {FormErrorMessagesProvider} from '@stackworx/react-hook-form-mui'; + + + +; +``` + +```tsx +import {HelperTextProvider} from '@stackworx/react-hook-form-mui'; + +// A dense filter bar that shows no helper text needs no reserved lines. + + +; +``` + +## Core components + +### TextField + +Stores the input's string. `transform` maps the form value to the text and back. + +```tsx + value, output: (text) => text.trim()}} +/>; +``` + +### NumberField + +Stores `number | null`. Built from MUI's Base UI recipe, so it ships on its own entry point and needs +`@base-ui/react`. + +```tsx +import {NumberField} from '@stackworx/react-hook-form-mui/number-field'; + +; +``` + +### Select + +Stores the chosen option's `value`: `TValue | null`, or `TValue[]` with `multiple`. Numbers stay +numbers. + +```tsx + + ), + {defaultValues: {size: null}}, + ); + expect(screen.getByRole('combobox', {name: 'Size'})).not.toHaveTextContent( + /Ten|Twenty|Thirty/, + ); + await choose('Size', 'Twenty'); + expect(form.getValues('size')).toBe(20); + expect(screen.getByRole('combobox', {name: 'Size'})).toHaveTextContent( + 'Twenty', + ); +}); + +test('multiple stores an array and renders the chosen labels', async () => { + const {form} = renderWithForm<{sizes: number[]}>( + (control) => ( + + ), + {defaultValues: {size: null}}, + ); + expect(screen.getByText('Box size')).toBeInTheDocument(); + await act(() => form.trigger('size')); + expect(screen.getByText('Pick a size')).toBeInTheDocument(); + expect(screen.queryByText('Box size')).not.toBeInTheDocument(); +}); + +test('re-renders when the options change', async () => { + function Switcher({control}: {control: Control<{code: string | null}>}) { + const [spanish, setSpanish] = useState(false); + return ( + <> + + + ), + {defaultValues: {size: null}}, + ); + const combobox = screen.getByRole('combobox', {name: 'Size'}); + expect(combobox).not.toHaveFocus(); + form.setFocus('size'); + await waitFor(() => { + expect(combobox).toHaveFocus(); + }); +}); + +test('with suppressFormChange, a choice handleChange does not store is ignored', async () => { + const handleChange = vi.fn(); + const {form} = renderWithForm<{size: number | null}>( + (control) => ( + + value === null ? null : DateTime.fromISO(value), + output: (date) => date?.toISO() ?? null, + }} + /> + + + + )} + + ); +} + +export const BookATrip: Story = {name: 'Book a trip'}; diff --git a/src/stories/Checkbox.stories.tsx b/src/stories/Checkbox.stories.tsx index d471ed1..efb4141 100644 --- a/src/stories/Checkbox.stories.tsx +++ b/src/stories/Checkbox.stories.tsx @@ -1,75 +1,70 @@ -import { StoryFn, Meta } from '@storybook/react'; -import { useForm } from 'react-hook-form'; +import {Checkbox} from '@stackworx/react-hook-form-mui'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import { + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; -import { Checkbox } from '../../packages/mui/src/Checkbox'; -import { CheckboxWithLabel } from '../../packages/mui/src/CheckboxWithLabel'; -import { Form } from './Form'; +interface Args extends FormArgs, FieldArgs { + size: 'small' | 'medium' | 'large'; + color: + | 'primary' + | 'secondary' + | 'error' + | 'info' + | 'success' + | 'warning' + | 'default'; +} -export default { +const meta = { title: 'Core/Checkbox', - component: Checkbox, + component: documented(Checkbox), + args: { + ...formArgs, + ...fieldArgs, + label: 'I accept the terms', + helperText: 'Required to continue', + required: 'Please accept the terms', + size: 'medium', + color: 'primary', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + size: {control: 'inline-radio', options: ['small', 'medium', 'large']}, + }, parameters: { - layout: 'fullscreen', + controls: {include: [...formAndFieldControls, 'size', 'color']}, }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta; - -const WithFormControlLabel: StoryFn = (args: any) => { - const formProps = useForm<{ - checkbox: any; - }>({ - defaultValues: { - checkbox: false, - }, - }); - return ( -
- - - ); -}; - -const Template: StoryFn = (args: any) => { - const formProps = useForm<{ - checkbox: any; - }>({ - defaultValues: { - checkbox: false, - }, - }); - return ( -
- - - ); -}; - -export const Default = { - render: Template, -}; + render: (args) => ( + + defaultValues={{accept: false}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; -export const Required = { - render: Template, +type Story = StoryObj; - args: { - rules: { required: 'Required' }, - }, -}; - -export const WithFormLabel = { - render: WithFormControlLabel, - args: { - rules: { required: 'Required' }, - }, -}; +export const Default: Story = {}; diff --git a/src/stories/CheckboxGroup.stories.tsx b/src/stories/CheckboxGroup.stories.tsx index 551d1b1..6fe15e6 100644 --- a/src/stories/CheckboxGroup.stories.tsx +++ b/src/stories/CheckboxGroup.stories.tsx @@ -1,83 +1,81 @@ -import { StoryFn, Meta } from '@storybook/react'; -import { useForm } from 'react-hook-form'; -import FormGroup from '@mui/material/FormGroup'; -import FormControlLabel from '@mui/material/FormControlLabel'; +import {CheckboxGroup} from '@stackworx/react-hook-form-mui'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import { + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; -import { CheckboxGroup } from '../../packages/mui/src/CheckboxGroup'; -import { Form } from './Form'; +interface Args extends FormArgs, FieldArgs { + row: boolean; + size: 'small' | 'medium' | 'large'; + color: + | 'primary' + | 'secondary' + | 'error' + | 'info' + | 'success' + | 'warning' + | 'default'; +} -export default { +const meta = { title: 'Core/CheckboxGroup', - component: CheckboxGroup, + component: documented(CheckboxGroup), + args: { + ...formArgs, + ...fieldArgs, + label: 'Delivery days', + required: 'Pick at least one day', + row: true, + size: 'medium', + color: 'primary', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + size: {control: 'inline-radio', options: ['small', 'medium', 'large']}, + }, parameters: { - layout: 'fullscreen', + controls: {include: [...formAndFieldControls, 'row', 'size', 'color']}, }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta; + render: (args) => ( + + defaultValues={{days: [1, 2, 3, 4, 5]}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; -const Template: StoryFn = (args: any) => { - const formProps = useForm<{ - colours: any; - }>({ - defaultValues: { - colours: [], - }, - }); - return ( -
- - - } - label="Red" - > - - - - } - label="Green" - > - - - - } - label="Blue" - > - -
- ); -}; +type Story = StoryObj; -export const Default = { - render: Template, -}; - -export const Required = { - render: Template, - args: { - rules: { required: 'Required' }, - }, -}; +export const Default: Story = {}; diff --git a/src/stories/DateField.stories.tsx b/src/stories/DateField.stories.tsx new file mode 100644 index 0000000..10704ee --- /dev/null +++ b/src/stories/DateField.stories.tsx @@ -0,0 +1,172 @@ +import {DateField} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + initial?: number; + minDate?: number; + maxDate?: number; + disablePast: boolean; + disableFuture: boolean; + readOnly: boolean; + format: string; + messages?: PickerErrorMessages; +} + +const meta = { + title: 'MUI X/DateField', + component: documented(DateField), + args: { + ...formArgs, + ...fieldArgs, + label: 'Due date', + disablePast: false, + disableFuture: false, + readOnly: false, + format: '', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initial: { + control: 'date', + description: "The field's starting value.", + table: {category: 'Field'}, + }, + minDate: {control: 'date'}, + maxDate: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, + }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initial', + 'minDate', + 'maxDate', + 'disablePast', + 'disableFuture', + 'readOnly', + 'format', + ], + }, + }, + render: (args) => ( + + // A new starting value needs a new form: default values are read once. + key={String(args.initial)} + defaultValues={{dueDate: fromDateControl(args.initial) ?? null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Required: Story = {args: {required: 'Enter a due date'}}; + +export const MinAndMax: Story = { + args: { + minDate: dateControl('2026-01-01'), + maxDate: dateControl('2026-12-31'), + helperText: 'During 2026', + }, +}; + +export const CustomMessages: Story = { + args: { + label: 'Date of birth', + disableFuture: true, + maxDate: DateTime.now().minus({years: 16}).toMillis(), + messages: { + disableFuture: 'A date of birth cannot be in the future', + maxDate: 'You must be at least 16', + }, + }, + render: (args) => ( + + defaultValues={{dateOfBirth: null}} + settings={args} + > + {(control) => ( + + )} + + ), +}; + +export const Disabled: Story = { + args: { + disabled: true, + initial: dateControl('2026-11-30'), + helperText: 'Agreed when the order was placed', + }, +}; + +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: {helperText: 'Stored as an ISO 8601 date (yyyy-MM-dd)'}, + render: (args) => ( + + defaultValues={{dueDate: '2026-11-30'}} + settings={args} + > + {(control) => ( + (value === null ? null : DateTime.fromISO(value)), + output: (date) => date?.toISODate() ?? null, + }} + /> + )} + + ), +}; diff --git a/src/stories/DatePicker.stories.tsx b/src/stories/DatePicker.stories.tsx index 6672719..d85ad52 100644 --- a/src/stories/DatePicker.stories.tsx +++ b/src/stories/DatePicker.stories.tsx @@ -1,140 +1,155 @@ -import { Meta } from '@storybook/react'; -import { DatePicker } from '../../packages/x-date-pickers/src/DatePicker'; -import dayjs from 'dayjs'; -import { FormDecorator } from '../decorators/FormDecorator'; -import { ComponentProps } from 'react'; -import { UseFormProps } from 'react-hook-form/dist/types'; +import {DatePicker} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; -export default { - title: 'MUI-X/DatePicker', - decorators: [ - (Story, context) => { - return ( - - - - ); - }, - ], - component: DatePicker, - parameters: { - layout: 'fullscreen', - }, - args: { - name: 'picker', - form: { - defaultValues: { picker: dayjs().toDate() }, - }, - }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta & { form: UseFormProps }>; - -export const Default = { - args: { - label: 'Default', - }, -}; +interface Args extends FormArgs, FieldArgs { + initial?: number; + minDate?: number; + maxDate?: number; + disablePast: boolean; + disableFuture: boolean; + closeOnSelect: boolean; + readOnly: boolean; + format: string; + messages?: PickerErrorMessages; +} -export const Required = { +const meta = { + title: 'MUI X/DatePicker', + component: documented(DatePicker), args: { - label: 'Required', - rules: { required: true, message: 'This fields is required' }, + ...formArgs, + ...fieldArgs, + label: 'Delivery date', + disablePast: false, + disableFuture: false, + closeOnSelect: true, + readOnly: false, + format: '', }, -}; - -export const WithHelperText = { - args: { - label: 'With Helper Text', - rules: { required: 'This field is required' }, - slotProps: { - textField: { - helperText: 'Will be replaced with error message...', - }, - }, - }, -}; - -export const InvalidDate = { - args: { - label: 'Invalid Date', - form: { - defaultValues: { picker: '2024-66-81' }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initial: { + control: 'date', + description: "The field's starting value.", + table: {category: 'Field'}, }, + minDate: {control: 'date'}, + maxDate: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, }, -}; - -export const DisablePast = { - args: { - form: { - defaultValues: { picker: dayjs().subtract(1, 'day').toDate() }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initial', + 'minDate', + 'maxDate', + 'disablePast', + 'disableFuture', + 'closeOnSelect', + 'readOnly', + 'format', + ], }, - label: 'Disable Past', - disablePast: true, }, -}; + render: (args) => ( + + // A new starting value needs a new form: default values are read once. + key={String(args.initial)} + defaultValues={{deliveryDate: fromDateControl(args.initial) ?? null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; -export const DisableFuture = { - args: { - form: { - defaultValues: { picker: dayjs().add(1, 'day').toDate() }, - }, - label: 'Disable Future', - disableFuture: true, - }, -}; +type Story = StoryObj; -export const MaxDate = { - args: { - label: 'Max Date', - maxDate: dayjs().subtract(1, 'day').toDate(), - }, -}; +export const Required: Story = {args: {required: 'Pick a delivery date'}}; -export const MinDate = { +export const MinAndMax: Story = { args: { - label: 'Min Date', - minDate: dayjs().add(1, 'day').toDate(), + minDate: dateControl('2026-10-01'), + maxDate: dateControl('2026-10-31'), + helperText: 'During October 2026', }, }; -export const ShouldDisableDate = { +export const CustomMessages: Story = { args: { - label: 'Should Disable Date', - form: { defaultValues: { picker: dayjs().add(1, 'day').toDate() } }, - shouldDisableDate: (dateParam) => { - const tomorrow = dayjs().add(1, 'day').startOf('day'); - const selectedDate = dayjs(dateParam).startOf('day'); - - return selectedDate.isSame(tomorrow); + disablePast: true, + helperText: 'Today or later', + messages: { + disablePast: 'We cannot deliver in the past', + invalidDate: 'That date does not exist', }, }, }; -export const ShouldDisableMonth = { +export const Disabled: Story = { args: { - label: 'Should Disable Month (Next month not allowed)', - form: { defaultValues: { picker: dayjs().add(1, 'month').toDate() } }, - shouldDisableMonth: (dateParam) => { - const month = dayjs().add(1, 'month').startOf('month'); - const selectedMonth = dayjs(dateParam).startOf('month'); - - return selectedMonth.isSame(month); - }, + disabled: true, + initial: dateControl('2026-10-05'), + helperText: 'Booked deliveries cannot be moved', }, }; -export const ShouldDisableYear = { - args: { - label: 'Should Disable Year (2025 not allowed)', - // defaultValue: dayjs().year(2025).month(0).date(1).toDate(), - form: { - defaultValues: { picker: dayjs().year(2025).month(0).date(1).toDate() }, - }, - shouldDisableYear: (dateParam) => { - const disabledYear = 2025; - const selectedYear = dayjs(dateParam).year(); - - return selectedYear === disabledYear; - }, - }, +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: {helperText: 'Stored as an ISO 8601 date (yyyy-MM-dd)'}, + render: (args) => ( + + defaultValues={{deliveryDate: '2026-10-05'}} + settings={args} + > + {(control) => ( + (value === null ? null : DateTime.fromISO(value)), + output: (date) => date?.toISODate() ?? null, + }} + /> + )} + + ), }; diff --git a/src/stories/DateRangePicker.stories.tsx b/src/stories/DateRangePicker.stories.tsx new file mode 100644 index 0000000..8978001 --- /dev/null +++ b/src/stories/DateRangePicker.stories.tsx @@ -0,0 +1,218 @@ +import type {DateRange} from '@mui/x-date-pickers-pro/models'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import {DateRangePicker} from '@stackworx/react-hook-form-mui-x-date-pickers-pro'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + initialFrom?: number; + initialTo?: number; + minDate?: number; + maxDate?: number; + disablePast: boolean; + disableFuture: boolean; + readOnly: boolean; + format: string; + calendars: 1 | 2 | 3; + shouldDisableDate?: (day: DateTime) => boolean; + validate?: Record) => true | string>; + messages?: PickerErrorMessages; +} + +// RHF's `required` counts [null, null] as a value, so the shared control checks both ends. +function requireBothEnds(args: FieldArgs, start: unknown, end: unknown) { + return args.required === '' || (start !== null && end !== null) + || args.required; +} + +const meta = { + title: 'MUI X Pro/DateRangePicker', + component: documented(DateRangePicker), + args: { + ...formArgs, + ...fieldArgs, + label: 'Leave period', + disablePast: false, + disableFuture: false, + readOnly: false, + format: '', + calendars: 2, + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initialFrom: { + control: 'date', + description: "The start of the field's starting value.", + table: {category: 'Field'}, + }, + initialTo: { + control: 'date', + description: "The end of the field's starting value.", + table: {category: 'Field'}, + }, + minDate: {control: 'date'}, + maxDate: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, + calendars: {control: 'inline-radio', options: [1, 2, 3]}, + }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initialFrom', + 'initialTo', + 'minDate', + 'maxDate', + 'disablePast', + 'disableFuture', + 'readOnly', + 'format', + 'calendars', + ], + }, + }, + render: (args) => ( + }> + // A new starting value needs a new form: default values are read once. + key={String([args.initialFrom, args.initialTo])} + defaultValues={{ + leave: [ + fromDateControl(args.initialFrom) ?? null, + fromDateControl(args.initialTo) ?? null, + ], + }} + settings={args} + > + {(control) => ( + requireBothEnds(args, start, end), + ...args.validate, + }, + }} + minDate={fromDateControl(args.minDate)} + maxDate={fromDateControl(args.maxDate)} + disablePast={args.disablePast} + disableFuture={args.disableFuture} + readOnly={args.readOnly} + format={args.format === '' ? undefined : args.format} + calendars={args.calendars} + shouldDisableDate={args.shouldDisableDate} + messages={args.messages} + /> + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +interface Period { + from: string | null; + to: string | null; +} + +export const Required: Story = {args: {required: 'Pick both dates'}}; + +export const MinAndMax: Story = { + args: { + minDate: dateControl('2026-10-01'), + maxDate: dateControl('2026-12-31'), + helperText: 'Between 1 October and 31 December 2026', + }, +}; + +export const CustomMessages: Story = { + args: { + shouldDisableDate: (day) => day.weekday > 5, + helperText: 'Starts and ends on a weekday', + messages: { + shouldDisableDate: 'Leave cannot start or end on a weekend', + invalidRange: 'The last day is before the first', + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + initialFrom: dateControl('2026-12-14'), + initialTo: dateControl('2026-12-18'), + helperText: 'Approved leave cannot be changed', + }, +}; + +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: { + helperText: 'Stored as {from, to}, each an ISO 8601 date (yyyy-MM-dd)', + }, + render: (args) => ( + + defaultValues={{leave: {from: '2026-12-14', to: '2026-12-18'}}} + settings={args} + > + {(control) => ( + requireBothEnds(args, from, to), + }, + }} + disablePast={args.disablePast} + disableFuture={args.disableFuture} + transform={{ + input: ({from, to}) => [ + from === null ? null : DateTime.fromISO(from), + to === null ? null : DateTime.fromISO(to), + ], + output: ([start, end]) => ({ + from: start?.toISODate() ?? null, + to: end?.toISODate() ?? null, + }), + }} + /> + )} + + ), +}; + +export const BothOrNeither: Story = { + args: { + helperText: 'Optional', + validate: { + bothOrNeither: ([start, end]) => + (start === null) === (end === null) || 'Pick both dates, or neither', + }, + }, +}; + +export const EndBeforeStart: Story = { + args: { + initialFrom: dateControl('2026-12-18'), + initialTo: dateControl('2026-12-14'), + helperText: 'Starts with the end before the start: submit to validate', + }, +}; diff --git a/src/stories/DateTimeField.stories.tsx b/src/stories/DateTimeField.stories.tsx new file mode 100644 index 0000000..43c6f49 --- /dev/null +++ b/src/stories/DateTimeField.stories.tsx @@ -0,0 +1,155 @@ +import {DateTimeField} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + initial?: number; + minDateTime?: number; + maxDateTime?: number; + disablePast: boolean; + disableFuture: boolean; + ampm: boolean; + readOnly: boolean; + format: string; + messages?: PickerErrorMessages; +} + +const meta = { + title: 'MUI X/DateTimeField', + component: documented(DateTimeField), + args: { + ...formArgs, + ...fieldArgs, + label: 'Departure', + disablePast: false, + disableFuture: false, + ampm: false, + readOnly: false, + format: '', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initial: { + control: 'date', + description: "The field's starting value.", + table: {category: 'Field'}, + }, + minDateTime: {control: 'date'}, + maxDateTime: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, + }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initial', + 'minDateTime', + 'maxDateTime', + 'disablePast', + 'disableFuture', + 'ampm', + 'readOnly', + 'format', + ], + }, + }, + render: (args) => ( + + // A new starting value needs a new form: default values are read once. + key={String(args.initial)} + defaultValues={{departure: fromDateControl(args.initial) ?? null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Required: Story = { + args: {required: 'Enter the departure date and time'}, +}; + +export const MinAndMax: Story = { + args: { + minDateTime: dateControl('2026-12-01T06:00'), + maxDateTime: dateControl('2026-12-31T22:00'), + helperText: 'From 06:00 on 1 December to 22:00 on 31 December 2026', + }, +}; + +export const CustomMessages: Story = { + args: { + disablePast: true, + helperText: 'Later than now', + messages: {disablePast: 'The departure must be in the future'}, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + initial: dateControl('2026-12-18T07:45'), + helperText: 'Set by the timetable', + }, +}; + +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: {helperText: 'Stored as an ISO 8601 date-time with its offset'}, + render: (args) => ( + + defaultValues={{departure: '2026-12-18T07:45:00.000+02:00'}} + settings={args} + > + {(control) => ( + (value === null ? null : DateTime.fromISO(value)), + output: (date) => date?.toISO() ?? null, + }} + /> + )} + + ), +}; diff --git a/src/stories/DateTimePicker.stories.tsx b/src/stories/DateTimePicker.stories.tsx index 72193f5..5b91487 100644 --- a/src/stories/DateTimePicker.stories.tsx +++ b/src/stories/DateTimePicker.stories.tsx @@ -1,228 +1,165 @@ -import { Meta } from '@storybook/react'; -import { DateTimePicker } from '../../packages/x-date-pickers/src/DateTimePicker'; -import dayjs from 'dayjs'; -import { FormDecorator } from '../decorators/FormDecorator'; -import { UseFormProps } from 'react-hook-form/dist/types'; -import { ComponentProps } from 'react'; - -export default { - title: 'MUI-X/DateTimePicker', - decorators: [ - (Story, context) => { - return ( - - - - ); - }, - ], - component: DateTimePicker, - parameters: { - layout: 'fullscreen', - }, - args: { - name: 'picker', - form: { - defaultValues: { picker: dayjs().toDate() }, - }, - }, - actions: { - onSubmit: 'submit', - }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta & { form: UseFormProps }>; - -export const Default = { - args: { - label: 'Default', - }, -}; - -export const Required = { - args: { - label: 'Required', - rules: { required: true }, - form: { - defaultValues: { picker: undefined }, - }, - }, -}; - -export const WithHelperText = { - args: { - label: 'With Helper Text', - rules: { required: 'This field is required' }, - slotProps: { - textField: { - helperText: 'Will be replaced with error message...', - }, - }, - }, -}; - -export const InvalidDate = { - args: { - label: 'Invalid Date', - form: { - defaultValues: { picker: '2025' }, - }, - }, -}; - -export const DisablePast = { - args: { - form: { - defaultValues: { picker: dayjs().subtract(1, 'day').toDate() }, - }, - label: 'Disable Past', - disablePast: true, - }, -}; - -export const DisableFuture = { - args: { - form: { - defaultValues: { picker: dayjs().add(1, 'day').toDate() }, - }, - label: 'Disable Future', - disableFuture: true, - }, -}; - -export const MaxDate = { - args: { - label: 'Max Date', - maxDate: dayjs().subtract(1, 'day').toDate(), - }, -}; - -export const MinDate = { - args: { - label: 'Min Date', - minDate: dayjs().add(1, 'day').toDate(), - }, -}; - -export const MaxDateTime = { - args: { - label: 'Max Date Time', - maxDateTime: dayjs().subtract(1, 'hour').toDate(), - }, -}; - -export const MinDateTime = { - args: { - label: 'Min Date Time', - minDateTime: dayjs().add(1, 'hour').toDate(), - }, -}; - -export const MaxTime = { - args: { - label: 'Max Time', - maxTime: dayjs().subtract(1, 'hour').toDate(), - }, -}; - -export const MinTime = { - args: { - label: 'Max Time', - minTime: dayjs().add(1, 'hour').toDate(), - }, -}; - -export const MinutesStep = { - args: { - label: 'Minutes Step', - minutesStep: '15', - }, -}; - -export const ShouldDisableDate = { - args: { - label: 'Should Disable Date - (Tomorrow not allowed)', - form: { defaultValues: { picker: dayjs().add(1, 'day').toDate() } }, - shouldDisableDate: (dateParam) => { - const tomorrow = dayjs().add(1, 'day').startOf('day'); - const selectedDate = dayjs(dateParam).startOf('day'); - - return selectedDate.isSame(tomorrow); - }, - }, -}; - -export const ShouldDisableMonth = { - args: { - label: 'Should Disable Month (Next month not allowed)', - form: { defaultValues: { picker: dayjs().add(1, 'month').toDate() } }, - shouldDisableMonth: (dateParam) => { - const month = dayjs().add(1, 'month').startOf('month'); - const selectedMonth = dayjs(dateParam).startOf('month'); - - return selectedMonth.isSame(month); - }, - }, -}; - -export const ShouldDisableYear = { - args: { - label: 'Should Disable Year (2025 not allowed)', - // defaultValue: dayjs().year(2025).month(0).date(1).toDate(), - form: { - defaultValues: { picker: dayjs().year(2025).month(0).date(1).toDate() }, - }, - shouldDisableYear: (dateParam) => { - const disabledYear = 2025; - const selectedYear = dayjs(dateParam).year(); - - return selectedYear === disabledYear; - }, - }, -}; - -export const ShouldDisableTimeHours = { - args: { - label: 'Should Disable Time Hours (5AM not allowed)', - form: { - defaultValues: { picker: dayjs().hour(5).minute(0).second(0).toDate() }, - }, - shouldDisableTime: (timeParam) => { - const disabledHour = 5; - const selectedHour = dayjs(timeParam).hour(); - - return selectedHour === disabledHour; - }, - }, -}; - -export const ShouldDisableTimeMinutes = { - args: { - label: 'Should Disable Time Minutes (Half hour not allowed)', - form: { - defaultValues: { picker: dayjs().hour(5).minute(30).second(0).toDate() }, - }, - shouldDisableTime: (timeParam) => { - const disabledMinute = 30; - const selectedMinute = dayjs(timeParam).minute(); - - return selectedMinute === disabledMinute; - }, - }, -}; - -export const ShouldDisableTimeSeconds = { - args: { - label: 'Should Disable Time Seconds (45 seconds not allowed)', - // defaultValue: dayjs().minute(0).second(45).toDate(), - form: { - defaultValues: { picker: dayjs().minute(0).second(45).toDate() }, - }, - views: ['year', 'day', 'hours', 'minutes', 'seconds'], - shouldDisableTime: (timeParam) => { - const disabledSecond = 45; - const selectedSecond = dayjs(timeParam).second(); - - return selectedSecond === disabledSecond; - }, +import {DateTimePicker} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + initial?: number; + minDateTime?: number; + maxDateTime?: number; + disablePast: boolean; + disableFuture: boolean; + ampm: boolean; + closeOnSelect: boolean; + readOnly: boolean; + format: string; + shouldDisableDate?: (day: DateTime) => boolean; + minutesStep?: number; + messages?: PickerErrorMessages; +} + +const meta = { + title: 'MUI X/DateTimePicker', + component: documented(DateTimePicker), + args: { + ...formArgs, + ...fieldArgs, + label: 'Appointment', + disablePast: false, + disableFuture: false, + ampm: false, + closeOnSelect: false, + readOnly: false, + format: '', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initial: { + control: 'date', + description: "The field's starting value.", + table: {category: 'Field'}, + }, + minDateTime: {control: 'date'}, + maxDateTime: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initial', + 'minDateTime', + 'maxDateTime', + 'disablePast', + 'disableFuture', + 'ampm', + 'closeOnSelect', + 'readOnly', + 'format', + ], + }, + }, + render: (args) => ( + + // A new starting value needs a new form: default values are read once. + key={String(args.initial)} + defaultValues={{appointment: fromDateControl(args.initial) ?? null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Required: Story = {args: {required: 'Pick a date and time'}}; + +export const MinAndMax: Story = { + args: { + minDateTime: dateControl('2026-10-05T08:00'), + maxDateTime: dateControl('2026-10-09T17:00'), + helperText: 'From 08:00 on 5 October to 17:00 on 9 October 2026', + }, +}; + +export const CustomMessages: Story = { + args: { + shouldDisableDate: (day) => day.weekday > 5, + minutesStep: 15, + helperText: 'Weekdays, on the quarter hour', + messages: { + shouldDisableDate: 'Appointments are on weekdays only', + minutesStep: 'Pick a quarter-hour slot', + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + initial: dateControl('2026-10-05T10:30'), + helperText: 'Confirmed appointments cannot be moved', + }, +}; + +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: {helperText: 'Stored as an ISO 8601 date-time with its offset'}, + render: (args) => ( + + defaultValues={{appointment: '2026-10-05T10:30:00.000+02:00'}} + settings={args} + > + {(control) => ( + (value === null ? null : DateTime.fromISO(value)), + output: (date) => date?.toISO() ?? null, + }} + /> + )} + + ), }; diff --git a/src/stories/DateTimeRangePicker.stories.tsx b/src/stories/DateTimeRangePicker.stories.tsx new file mode 100644 index 0000000..7bfec69 --- /dev/null +++ b/src/stories/DateTimeRangePicker.stories.tsx @@ -0,0 +1,225 @@ +import type {DateRange} from '@mui/x-date-pickers-pro/models'; +import type {PickerErrorMessages} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import {DateTimeRangePicker} from '@stackworx/react-hook-form-mui-x-date-pickers-pro'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {DateTime} from 'luxon'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + initialFrom?: number; + initialTo?: number; + minDateTime?: number; + maxDateTime?: number; + disablePast: boolean; + disableFuture: boolean; + ampm: boolean; + readOnly: boolean; + format: string; + calendars: 1 | 2 | 3; + minutesStep?: number; + validate?: Record) => true | string>; + messages?: PickerErrorMessages; +} + +// RHF's `required` counts [null, null] as a value, so the shared control checks both ends. +function requireBothEnds(args: FieldArgs, start: unknown, end: unknown) { + return args.required === '' || (start !== null && end !== null) + || args.required; +} + +const meta = { + title: 'MUI X Pro/DateTimeRangePicker', + component: documented(DateTimeRangePicker), + args: { + ...formArgs, + ...fieldArgs, + label: 'Booking', + disablePast: false, + disableFuture: false, + ampm: false, + readOnly: false, + format: '', + calendars: 1, + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + initialFrom: { + control: 'date', + description: "The start of the field's starting value.", + table: {category: 'Field'}, + }, + initialTo: { + control: 'date', + description: "The end of the field's starting value.", + table: {category: 'Field'}, + }, + minDateTime: {control: 'date'}, + maxDateTime: {control: 'date'}, + format: {control: 'text', description: "Empty uses the locale's format."}, + calendars: {control: 'inline-radio', options: [1, 2, 3]}, + }, + parameters: { + controls: { + include: [ + ...formAndFieldControls, + 'initialFrom', + 'initialTo', + 'minDateTime', + 'maxDateTime', + 'disablePast', + 'disableFuture', + 'ampm', + 'readOnly', + 'format', + 'calendars', + ], + }, + }, + render: (args) => ( + }> + // A new starting value needs a new form: default values are read once. + key={String([args.initialFrom, args.initialTo])} + defaultValues={{ + booking: [ + fromDateControl(args.initialFrom) ?? null, + fromDateControl(args.initialTo) ?? null, + ], + }} + settings={args} + > + {(control) => ( + requireBothEnds(args, start, end), + ...args.validate, + }, + }} + minDateTime={fromDateControl(args.minDateTime)} + maxDateTime={fromDateControl(args.maxDateTime)} + disablePast={args.disablePast} + disableFuture={args.disableFuture} + ampm={args.ampm} + readOnly={args.readOnly} + format={args.format === '' ? undefined : args.format} + calendars={args.calendars} + minutesStep={args.minutesStep} + messages={args.messages} + /> + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Required: Story = {args: {required: 'Pick a start and an end'}}; + +export const MinAndMax: Story = { + args: { + minDateTime: dateControl('2026-10-10T08:00'), + maxDateTime: dateControl('2026-10-11T18:00'), + helperText: 'From 08:00 on 10 October to 18:00 on 11 October 2026', + }, +}; + +export const CustomMessages: Story = { + args: { + minutesStep: 30, + helperText: 'On the hour or half hour', + messages: { + minutesStep: 'Bookings start and end on the hour or half hour', + invalidRange: 'The booking ends before it starts', + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + initialFrom: dateControl('2026-10-10T09:00'), + initialTo: dateControl('2026-10-10T12:00'), + helperText: 'Confirmed bookings cannot be changed', + }, +}; + +export const IsoString: Story = { + name: 'Stored as an ISO string', + args: { + helperText: + 'Stored as {from, to}, each an ISO 8601 date-time with its offset', + }, + render: (args) => ( + + defaultValues={{ + booking: { + from: '2026-10-10T09:00:00.000+02:00', + to: '2026-10-10T12:00:00.000+02:00', + }, + }} + settings={args} + > + {(control) => ( + requireBothEnds(args, from, to), + }, + }} + disablePast={args.disablePast} + disableFuture={args.disableFuture} + ampm={args.ampm} + transform={{ + input: ({from, to}) => [ + from === null ? null : DateTime.fromISO(from), + to === null ? null : DateTime.fromISO(to), + ], + output: ([start, end]) => ({ + from: start?.toISO() ?? null, + to: end?.toISO() ?? null, + }), + }} + /> + )} + + ), +}; + +export const BothOrNeither: Story = { + args: { + helperText: 'Optional', + validate: { + bothOrNeither: ([start, end]) => + (start === null) === (end === null) + || 'Pick a start and an end, or neither', + }, + }, +}; + +export const EndBeforeStart: Story = { + args: { + initialFrom: dateControl('2026-10-10T12:00'), + initialTo: dateControl('2026-10-10T09:00'), + helperText: 'Starts with the end before the start: submit to validate', + }, +}; diff --git a/src/stories/Form.tsx b/src/stories/Form.tsx deleted file mode 100644 index 49fac8c..0000000 --- a/src/stories/Form.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from 'react'; -import { - FieldValues, - FormProvider, - FormProviderProps, - SubmitHandler, -} from 'react-hook-form'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Stack from '@mui/material/Stack'; -import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; - -interface FormProps - extends FormProviderProps { - children: React.ReactNode; - onSubmit: SubmitHandler; -} - -export function Form({ - children, - onSubmit, - ...props -}: FormProps) { - return ( - - -
- - {children} - - - - - -
-
-
- ); -} diff --git a/src/stories/FormErrorMessages.stories.tsx b/src/stories/FormErrorMessages.stories.tsx new file mode 100644 index 0000000..b4293f8 --- /dev/null +++ b/src/stories/FormErrorMessages.stories.tsx @@ -0,0 +1,123 @@ +import { + Checkbox, + FormErrorMessagesProvider, + TextField, +} from '@stackworx/react-hook-form-mui'; +import {NumberField} from '@stackworx/react-hook-form-mui/number-field'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {documented, formArgs, formArgTypes, formControls} from './controls'; +import type {FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Order { + name: string; + postalCode: string; + quantity: number | null; + terms: boolean; +} + +const meta = { + title: 'Core/FormErrorMessagesProvider', + component: documented(FormErrorMessagesProvider), + args: {...formArgs}, + argTypes: {...formArgTypes}, + parameters: { + controls: {include: formControls}, + }, + render: (args) => ( + + + defaultValues={{name: '', postalCode: '', quantity: null, terms: false}} + settings={args} + > + {(control) => ( + <> + + + + + + )} + + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const RulesWithoutMessages: Story = {}; + +export const Afrikaans: Story = { + render: (args) => ( + + + defaultValues={{name: '', postalCode: '', quantity: null, terms: false}} + settings={args} + > + {(control) => ( + <> + + + + + + )} + + + ), +}; diff --git a/src/stories/FormStory.tsx b/src/stories/FormStory.tsx new file mode 100644 index 0000000..1f6d9d2 --- /dev/null +++ b/src/stories/FormStory.tsx @@ -0,0 +1,109 @@ +import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { + HelperTextProvider, + useFormErrorMessages, +} from '@stackworx/react-hook-form-mui'; +import type {ReactNode} from 'react'; +import {useForm, useFormState, useWatch} from 'react-hook-form'; +import type {Control, DefaultValues, FieldValues} from 'react-hook-form'; +import {action} from 'storybook/actions'; +import {formArgs} from './controls'; +import type {FormArgs} from './controls'; + +function FormValues({control}: {control: Control}) { + const values = useWatch({control}); + const {errors, isDirty, isValid, isSubmitted} = useFormState({control}); + const messages = useFormErrorMessages(); + const errorMessages = Object.fromEntries( + Object.entries(errors).map(([name, error]) => { + const type = typeof error?.type === 'string' ? error.type : undefined; + const message = typeof error?.message === 'string' ? error.message : ''; + return [ + name, + message !== '' ? message : (type && (messages[type] ?? type)), + ]; + }), + ); + return ( + + Form state +
+        {JSON.stringify(
+          {values, errors: errorMessages, isDirty, isValid, isSubmitted},
+          null,
+          2,
+        )}
+      
+
+ ); +} + +function StoryForm({ + defaultValues, + settings, + children, +}: { + defaultValues?: DefaultValues; + settings: FormArgs; + children: (control: Control) => ReactNode; +}) { + const {control, handleSubmit, reset} = useForm({ + defaultValues, + mode: settings.formMode, + disabled: settings.formDisabled, + }); + return ( + +
{ + void handleSubmit(action('submit'))(event); + }} + > + + {children(control)} + + + + + + +
+
+ ); +} + +/** + * Renders fields inside a form and shows the live values with `useWatch` (never `watch()`). `settings` + * are the story's Form controls. + */ +export function FormStory({ + defaultValues, + settings, + children, +}: { + defaultValues?: DefaultValues; + settings?: Partial; + children: (control: Control) => ReactNode; +}) { + const resolved = {...formArgs, ...settings}; + // React Hook Form reads `mode` when it creates the form, so a new mode needs a new form. + return ( + + key={resolved.formMode} + defaultValues={defaultValues} + settings={resolved} + > + {children} + + ); +} diff --git a/src/stories/NumberField.stories.tsx b/src/stories/NumberField.stories.tsx new file mode 100644 index 0000000..a384903 --- /dev/null +++ b/src/stories/NumberField.stories.tsx @@ -0,0 +1,86 @@ +import {NumberField} from '@stackworx/react-hook-form-mui/number-field'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import { + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + min: number; + max: number; + step: number; + size: 'small' | 'medium'; +} + +const meta = { + title: 'Core/NumberField', + component: documented(NumberField), + args: { + ...formArgs, + ...fieldArgs, + label: 'Study hours', + helperText: 'Per week', + required: 'Hours are required', + min: 0, + max: 60, + step: 0.5, + size: 'medium', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + min: { + control: 'number', + description: 'The lowest value the field allows.', + }, + max: { + control: 'number', + description: 'The highest value the field allows.', + }, + step: { + control: 'number', + description: 'How much the buttons and arrow keys change the value.', + }, + size: {control: 'inline-radio', options: ['small', 'medium']}, + }, + parameters: { + controls: { + include: [...formAndFieldControls, 'min', 'max', 'step', 'size'], + }, + }, + render: (args) => ( + + defaultValues={{hours: null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/stories/PickerBuildingBlocks.stories.tsx b/src/stories/PickerBuildingBlocks.stories.tsx new file mode 100644 index 0000000..66e9ba2 --- /dev/null +++ b/src/stories/PickerBuildingBlocks.stories.tsx @@ -0,0 +1,144 @@ +import {MobileDatePicker as MuiMobileDatePicker} from '@mui/x-date-pickers/MobileDatePicker'; +import type {MobileDatePickerProps as MuiMobileDatePickerProps} from '@mui/x-date-pickers/MobileDatePicker'; +import type { + DateValidationError, + PickerValidDate, +} from '@mui/x-date-pickers/models'; +import { + pickerTextFieldSlotProps, + pickerValueProps, + splitPickerProps, + usePickerController, +} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {PickerControllerProps} from '@stackworx/react-hook-form-mui-x-date-pickers'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {DateTime} from 'luxon'; +import type {ReactNode} from 'react'; +import type {FieldPath, FieldValues} from 'react-hook-form'; +import { + dateControl, + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + fromDateControl, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; + +interface Args extends FormArgs, FieldArgs { + minDate?: number; + closeOnSelect: boolean; + format: string; +} + +const meta = { + title: 'MUI X/Building blocks', + component: documented(MobileDatePicker), + args: { + ...formArgs, + ...fieldArgs, + label: 'Delivery date', + closeOnSelect: false, + format: '', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + format: {control: 'text', description: "Empty uses the locale's format."}, + }, + parameters: { + controls: { + include: [...formAndFieldControls, 'closeOnSelect', 'format'], + }, + }, + render: (args) => ( + + defaultValues={{deliveryDate: null}} + settings={args} + > + {(control) => ( + + )} + + ), +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +type MobileDatePickerProps< + TFieldValues extends FieldValues, + TName extends FieldPath, + TTransformedValues = TFieldValues, +> = + & PickerControllerProps< + TFieldValues, + TName, + PickerValidDate | null, + TTransformedValues + > + & Omit< + MuiMobileDatePickerProps, + 'value' | 'defaultValue' | 'onChange' | 'name' | 'disabled' | 'inputRef' + > + & {helperText?: ReactNode}; + +/** MUI X MobileDatePicker bound to RHF, assembled the way the library's DatePicker is. */ +function MobileDatePicker< + TFieldValues extends FieldValues, + TName extends FieldPath, + TTransformedValues = TFieldValues, +>(props: MobileDatePickerProps) { + const [controllerProps, {helperText, onError, slotProps, ...rest}] = + splitPickerProps< + TFieldValues, + TName, + PickerValidDate | null, + MobileDatePickerProps, + TTransformedValues + >(props); + const picker = usePickerController< + TFieldValues, + TName, + PickerValidDate | null, + DateValidationError, + TTransformedValues + >(controllerProps, null); + + return ( + + ); +} + +export const MobileDatePickerBinding: Story = { + name: 'MobileDatePicker', + args: { + minDate: dateControl('2026-10-01'), + helperText: 'Opens in a dialog on every screen size', + required: 'Pick a delivery date', + }, +}; diff --git a/src/stories/RadioGroup.stories.tsx b/src/stories/RadioGroup.stories.tsx index a8c19e9..f97f282 100644 --- a/src/stories/RadioGroup.stories.tsx +++ b/src/stories/RadioGroup.stories.tsx @@ -1,68 +1,76 @@ -import { StoryFn, Meta } from '@storybook/react'; -import { useForm } from 'react-hook-form'; +import {RadioGroup} from '@stackworx/react-hook-form-mui'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import { + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; -import { Radio, RadioGroup } from '../../packages/mui/src/RadioGroup'; -import { Form } from './Form'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormControl from '@mui/material/FormControl'; -import FormLabel from '@mui/material/FormLabel'; +interface Args extends FormArgs, FieldArgs { + row: boolean; + size: 'small' | 'medium'; + color: + | 'primary' + | 'secondary' + | 'error' + | 'info' + | 'success' + | 'warning' + | 'default'; +} -export default { +const meta = { title: 'Core/RadioGroup', - component: RadioGroup, + component: documented(RadioGroup), + args: { + ...formArgs, + ...fieldArgs, + label: 'Is this a gift?', + required: 'Choose one', + row: true, + size: 'medium', + color: 'primary', + }, + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + size: {control: 'inline-radio', options: ['small', 'medium']}, + }, parameters: { - layout: 'fullscreen', + controls: {include: [...formAndFieldControls, 'row', 'size', 'color']}, }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta; - -const Template: StoryFn = (args: any) => { - const formProps = useForm<{ - radioGroup: any; - }>({ - defaultValues: { - radioGroup: false, - }, - }); - return ( -
- - Gender + render: (args) => ( + + defaultValues={{gift: null}} + settings={args} + > + {(control) => ( - } - label="Female" - /> - } - label="Male" - /> - } - label="Other" - /> - - -
- ); -}; + name='gift' + control={control} + {...fieldProps(args)} + rules={{ + // RHF's `required` rejects `false`, which is what "No" stores. + validate: (value) => value !== null || (requiredRule(args) ?? true), + }} + row={args.row} + size={args.size} + color={args.color} + options={[{value: true, label: 'Yes'}, {value: false, label: 'No'}]} + /> + )} + + ), +} satisfies Meta; +export default meta; -export const Default = { - render: Template, -}; +type Story = StoryObj; -export const Required = { - render: Template, - - args: { - rules: { required: 'This field is required' }, - }, -}; +export const Default: Story = {}; diff --git a/src/stories/Select.stories.tsx b/src/stories/Select.stories.tsx index 74db6b4..0575f7d 100644 --- a/src/stories/Select.stories.tsx +++ b/src/stories/Select.stories.tsx @@ -1,117 +1,96 @@ -import Stack from '@mui/material/Stack'; -import MenuItem from '@mui/material/MenuItem'; -import { StoryFn, Meta } from '@storybook/react'; -import { useForm } from 'react-hook-form'; +import {Select} from '@stackworx/react-hook-form-mui'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import { + documented, + fieldArgs, + fieldArgTypes, + fieldProps, + formAndFieldControls, + formArgs, + formArgTypes, + requiredRule, +} from './controls'; +import type {FieldArgs, FormArgs} from './controls'; +import {FormStory} from './FormStory'; -import { Select } from '../../packages/mui/src/Select'; -import { Form } from './Form'; +interface Args extends FormArgs, FieldArgs { + size: 'small' | 'medium'; + variant: 'outlined' | 'filled' | 'standard'; +} -export default { +const lengths = [ + {value: 4, label: '4 hours'}, + {value: 8, label: '8 hours'}, + {value: 12, label: '12 hours'}, +]; + +const meta = { title: 'Core/Select', - component: Select, - parameters: { - layout: 'fullscreen', + component: documented(Select), + args: { + ...formArgs, + ...fieldArgs, + label: 'Booking length', + required: 'Pick a length', + size: 'medium', + variant: 'outlined', }, - argTypes: { onSubmit: { action: 'submit' } }, -} as Meta; - -const Template: StoryFn = (args: any) => { - const formProps = useForm<{ - text: any; - }>({ - defaultValues: { - text: args.SelectProps?.multiple ? [] : '', + argTypes: { + ...formArgTypes, + ...fieldArgTypes, + size: {control: 'inline-radio', options: ['small', 'medium']}, + variant: { + control: 'inline-radio', + options: ['outlined', 'filled', 'standard'], }, - }); - return ( -
- - + )} + + ), +} satisfies Meta; +export default meta; -export const SingleSelect = { - render: Template, +type Story = StoryObj; - args: { - label: 'Single Select', - children: [ - - Ten - , - - Twenty - , - - Thirty - , - ], - }, -}; - -export const MultipleSelect = { - render: Template, +export const Default: Story = {}; - args: { - label: 'Multiple Select', - SelectProps: { multiple: true }, - children: [ - - Ten - , - - Twenty - , - - Thirty - , - ], - }, +export const Multiple: Story = { + args: {label: 'Allowed lengths', required: ''}, + render: (args) => ( + + defaultValues={{lengths: []}} + settings={args} + > + {(control) => ( +