diff --git a/.changeset/config.json b/.changeset/config.json index d88011f6..2be13d43 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "fixed": [], "linked": [], - "access": "restricted", + "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] diff --git a/.github/workflows/front-dependencies-installation.yml b/.github/workflows/front-dependencies-installation.yml index cdd7ad31..34111314 100644 --- a/.github/workflows/front-dependencies-installation.yml +++ b/.github/workflows/front-dependencies-installation.yml @@ -18,7 +18,9 @@ jobs: uses: actions/cache@v5 id: front-node_modules with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} - name: Setup Node.js if: steps.front-node_modules.outputs.cache-hit != 'true' @@ -32,5 +34,7 @@ jobs: if: steps.front-node_modules.outputs.cache-hit != 'true' uses: actions/cache@v5 with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 96dda950..e5fb7509 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,7 +52,9 @@ jobs: - name: Restore the frontend cache uses: actions/cache@v5 with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true - name: Check linting @@ -71,7 +73,9 @@ jobs: - name: Restore the frontend cache uses: actions/cache@v5 with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true - name: Run tests @@ -90,7 +94,9 @@ jobs: - name: Restore the frontend cache uses: actions/cache@v5 with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true - name: Build @@ -109,7 +115,9 @@ jobs: - name: Restore the frontend cache uses: actions/cache@v5 with: - path: "node_modules" + path: | + node_modules + packages/*/node_modules key: front-node_modules-${{ hashFiles('yarn.lock') }} fail-on-cache-miss: true # Playwright browsers live outside node_modules and weigh hundreds of @@ -130,7 +138,7 @@ jobs: # The PDF viewer loads pdfjs's worker from /pdf.worker.mjs at runtime; # copy it from the installed pdfjs-dist so the served public/ has it. - name: Provision PDF worker - run: cp node_modules/pdfjs-dist/build/pdf.worker.mjs public/pdf.worker.mjs + run: yarn workspace @gouvfr-lasuite/ui-components copy-pdf-worker - name: Generate test PDFs run: yarn generate-test-pdfs - name: Run component tests @@ -141,6 +149,65 @@ jobs: with: name: playwright-report path: | - e2e/report - test-results + packages/ui-components/e2e/report + packages/ui-components/test-results retention-days: 7 + + storybook: + runs-on: ubuntu-latest + needs: install-front + permissions: + contents: read + actions: read + pages: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "20.x" + - name: Restore the frontend cache + uses: actions/cache@v5 + with: + path: | + node_modules + packages/*/node_modules + key: front-node_modules-${{ hashFiles('yarn.lock') }} + fail-on-cache-miss: true + - name: Configure GitHub Pages + id: pages + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/configure-pages@v5 + - name: Build Storybook + run: yarn build-storybook + env: + STORYBOOK_BASE_PATH: ${{ steps.pages.outputs.base_path }} + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v4 + with: + path: storybook-static + + deploy-storybook: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: + - storybook + - lint + - test + - build + - e2e + permissions: + pages: write + id-token: write + concurrency: + group: github-pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Storybook to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 107730c6..0bb0150a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -34,6 +34,11 @@ env.d/terraform # npm node_modules +.turbo +packages/*/.turbo +packages/*/dist +packages/ui-components/storybook-static +test-results # Logs @@ -47,15 +52,15 @@ node_modules .pytest_cache db.sqlite3 .mypy_cache -playwright/.cache/ +**/playwright/.cache/ test-results -e2e/report +**/e2e/report/ # Provisioned at build/test time from node_modules/pdfjs-dist -public/pdf.worker.mjs +**/public/pdf.worker.mjs # Generated by `yarn generate-test-pdfs` -public/storybook/preview-files/pdf_with_links.pdf -public/storybook/preview-files/pdf_with_js.pdf -public/storybook/preview-files/pdf_with_js_link.pdf +**/public/storybook/preview-files/pdf_with_links.pdf +**/public/storybook/preview-files/pdf_with_js.pdf +**/public/storybook/preview-files/pdf_with_js_link.pdf # Site media /data/ diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index a5e7896f..00000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { StorybookConfig } from "@storybook/react-vite"; - -const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], - addons: [ - "@storybook/addon-onboarding", - "@storybook/addon-essentials", - "@chromatic-com/storybook", - "@storybook/addon-interactions", - "@storybook/addon-a11y", - ], - - framework: { - name: "@storybook/react-vite", - options: {}, - }, - - // Fonts are served at "/" for dev mode and "/assets" for production builds. - // In production, Vite bundles CSS into "assets/preview-*.css" with relative - // url(./Marianne-*.woff2) references, resolving to "/assets/". - staticDirs: [ - "../src/assets/fonts/Marianne", - { from: "../src/assets/fonts/Marianne", to: "/assets" }, - // pdfjs-dist worker so PdfPreview's default workerSrc ("/pdf.worker.mjs") resolves. - { from: "../node_modules/pdfjs-dist/build", to: "/" }, - ], -}; -export default config; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e6a40484..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,19 +0,0 @@ -# Contributing - -## Release - -1. Run `yarn build` - -2. Update CHANGELOG.md according to the Major / Minor / Patch semver convention. - -3. Based on semver upgrade the package version in `package.json`. - -4. Commit the changes and create a PR named "🔖(release) version package". - -5. Ask for approval, once the PR is approved, merge it. - -6. Once merged, run `npx @changesets/cli publish`. It will publish the new version of the package to NPM and create a git tag. - -7. Run `git push origin ` - -8. Tell everyone 🎉 ! diff --git a/Dockerfile b/Dockerfile index ef24bf9d..09b3d6ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ WORKDIR /app RUN corepack enable COPY package.json yarn.lock ./ +COPY packages/ui-components/package.json packages/ui-components/package.json +COPY packages/ui-tokens/package.json packages/ui-tokens/package.json +COPY packages/ui-codemod/package.json packages/ui-codemod/package.json RUN yarn install --frozen-lockfile COPY . . diff --git a/README.md b/README.md index 2f72d214..7abd2edb 100644 --- a/README.md +++ b/README.md @@ -1,708 +1,52 @@ -# @gouvfr-lasuite/ui-kit +# La Suite numérique UI - -[![npm version](https://img.shields.io/npm/v/@gouvfr-lasuite/ui-kit.svg)](https://www.npmjs.com/package/@gouvfr-lasuite/ui-kit) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) -[![React](https://img.shields.io/badge/React-19.x-61DAFB?logo=react)](https://react.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)](https://www.typescriptlang.org/) +This repository is the canonical home of the La Suite numérique design system. +It combines the former UI Kit and Cunningham repositories in a private Yarn 1 +and Turborepo workspace. -> Official UI Kit for **La Suite numérique** - A modern, accessible, and customizable React component library built on [Cunningham](https://github.com/openfun/cunningham). +Browse the unified component documentation in the public +[Storybook](https://suitenumerique.github.io/ui-kit/). ---- +The Cunningham sources are a history-free snapshot of commit +[`3c13c21`](https://github.com/suitenumerique/cunningham/tree/3c13c21726d632a2a6ca41d2e7a4bbe28334c90b), +the React `4.3.1` release. -## Table of Contents +## Packages -- [Introduction](#introduction) -- [Features](#features) -- [Technologies](#technologies) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Project Structure](#project-structure) -- [Available Components](#available-components) -- [Themes](#themes) -- [Hooks](#hooks) -- [Internationalization](#internationalization) -- [Storybook Documentation](#storybook-documentation) -- [Development](#development) -- [Customization](#customization) -- [Usage Examples](#usage-examples) -- [Compatibility](#compatibility) -- [Contributing](#contributing) -- [License](#license) +| Package | Purpose | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| [`@gouvfr-lasuite/ui-components`](./packages/ui-components) | All React components, SVG icons, styles, fonts, tests and Storybook documentation from UI Kit and Cunningham | +| [`@gouvfr-lasuite/ui-tokens`](./packages/ui-tokens) | Cunningham token engine, CLI, default tokens, theming helpers and CSS/SCSS/JS/TS generators | +| [`@gouvfr-lasuite/ui-codemod`](./packages/ui-codemod) | One-off npx migration CLI for the former package names and public subpaths | ---- +The component package depends on the token package through its published semver +range. Package versions are independent and managed by Changesets. -## Introduction - -`@gouvfr-lasuite/ui-kit` is the official design system for **La Suite numérique**, the French government's collaborative tools ecosystem. This library provides a consistent set of reusable, accessible React components that comply with governmental design standards. - -Built on top of [Cunningham](https://github.com/openfun/cunningham) (v4), this kit extends the design system capabilities with components specific to La Suite numérique needs. - -### Goals - -- **Visual Consistency**: Ensure a uniform user experience across all La Suite numérique products -- **Accessibility**: Comply with WCAG and RGAA standards for maximum accessibility -- **Performance**: Optimized and tree-shakable components -- **Flexibility**: Support for multiple themes (DSFR, ANCT, White Label) -- **Developer-friendly**: Native TypeScript, comprehensive documentation, easy integration - ---- - -## Features - -- **25+ components** ready to use -- **Multiple themes**: DSFR, ANCT, White Label (light/dark) -- **Accessibility**: Keyboard navigation, screen readers, ARIA -- **Internationalization**: Built-in French and English support -- **Responsive**: Adaptive components with dedicated hooks -- **Layouts**: Page layout system with resizable panels -- **La Gaufre Integration**: Component for La Suite numérique services integration -- **TypeScript**: Full typing for better DX -- **Design tokens**: Customizable CSS/SCSS/TS token system - ---- - -## Technologies - -| Technology | Version | Usage | -|------------|---------|-------| -| React | 19.x | UI Framework | -| TypeScript | 5.6 | Static typing | -| Vite | 6.x | Bundler / Dev server | -| Cunningham | 4.1 | Base design system | -| Storybook | 8.5 | Documentation / Playground | -| Sass | 1.83 | CSS preprocessor | -| Vitest | 2.1 | Unit testing | -| react-aria-components | 1.8 | Accessible primitives | -| react-resizable-panels | 2.1 | Resizable panels | -| dnd-kit | 6.3 | Drag and Drop | - ---- - -## Prerequisites - -- **Node.js**: >= 20.x -- **Package manager**: yarn (recommended) or npm -- **React**: 19.x - ---- - -## Installation - -### Via Yarn (recommended) - -```bash -yarn add @gouvfr-lasuite/ui-kit -``` - -### Via npm - -```bash -npm install @gouvfr-lasuite/ui-kit -``` - -### Peer dependencies - -Make sure you have the peer dependencies installed: - -```bash -yarn add react react-dom -``` - ---- - -## Quick Start - -### 1. Import styles - -In your entry file (e.g., `main.tsx` or `App.tsx`): - -```tsx -// UI Kit styles (includes Cunningham) -import "@gouvfr-lasuite/ui-kit/style"; - -// Optional: Marianne font -import "@gouvfr-lasuite/ui-kit/fonts/Marianne"; -``` - -### 2. Configure the Provider - -Wrap your application with the `CunninghamProvider`: - -```tsx -import { CunninghamProvider } from "@gouvfr-lasuite/ui-kit"; - -function App() { - return ( - - - - ); -} -``` - -### 3. Use components - -```tsx -import { MainLayout, QuickSearch, TreeView } from "@gouvfr-lasuite/ui-kit"; - -function MyPage() { - return ( - } - > - - {/* Your content */} - - ); -} -``` - ---- - -## Project Structure - -``` -ui-kit/ -├── src/ -│ ├── components/ # React components -│ │ ├── badge/ # Badge -│ │ ├── button/ # Buttons (ProConnect) -│ │ ├── datagrid/ # Data grid -│ │ ├── dnd/ # Drag and Drop -│ │ ├── dropdown-menu/ # Dropdown menu -│ │ ├── filter/ # Filters -│ │ ├── footer/ # Footer -│ │ ├── form/ # Form fields -│ │ ├── hero/ # Hero section -│ │ ├── icon/ # Icons -│ │ ├── la-gaufre/ # La Suite integration -│ │ ├── language/ # Language selector -│ │ ├── layout/ # Layouts and panels -│ │ ├── loader/ # Loading indicators -│ │ ├── modal/ # Modals -│ │ ├── Provider/ # Cunningham Provider -│ │ ├── quick-search/ # Quick search -│ │ ├── separator/ # Separators -│ │ ├── share/ # Sharing and permissions -│ │ ├── tabs/ # Tabs -│ │ ├── tooltip/ # Tooltips -│ │ ├── tree-view/ # Tree view -│ │ └── users/ # User avatars and menus -│ ├── hooks/ # Custom React hooks -│ ├── locales/ # Translation files -│ ├── styles/ # Global styles and variables -│ ├── assets/ # Resources (fonts, images) -│ └── utils/ # Utilities -├── .storybook/ # Storybook configuration -├── cunningham.ts # Design tokens configuration -└── dist/ # Production build -``` - ---- - -## Available Components - -### Layout & Navigation - -| Component | Description | -|-----------|-------------| -| `MainLayout` | Main layout with header and resizable panels | -| `Header` | Application header | -| `LeftPanel` | Left side panel (navigation) | -| `RightPanel` | Right side panel (details) | -| `Footer` | Official footer | -| `Tabs` | Tab navigation | -| `DropdownMenu` | Contextual dropdown menu | - -### Forms - -| Component | Description | -|-----------|-------------| -| `Input` | Text input field | -| `Textarea` | Multi-line text area | -| `Select` | Selection list | -| `Checkbox` | Checkbox | -| `Radio` | Radio button | -| `Switch` | Toggle switch | -| `Label` | Field label | - -### Data & Display - -| Component | Description | -|-----------|-------------| -| `TreeView` | Interactive tree view with pagination | -| `Datagrid` | Data grid | -| `QuickSearch` | Quick search bar (cmd+k) | -| `Badge` | Status badge | -| `Icon` | Material icons | -| `Loader` | Loading indicator | -| `Tooltip` | Tooltip | - -### Sharing & Collaboration - -| Component | Description | -|-----------|-------------| -| `ShareModal` | Share modal with access management | -| `UserAvatar` | User avatar | -| `UserMenu` | User menu with logout | -| `UsersInvitation` | User invitation | - -### Integration - -| Component | Description | -|-----------|-------------| -| `LaGaufre` | La Suite numérique services menu | -| `LaGaufreV2` | Enhanced version of La Gaufre | -| `ProConnectButton` | ProConnect login button | - -### Utilities - -| Component | Description | -|-----------|-------------| -| `Modal` | Generic modal | -| `Separator` | Visual separator | -| `Hero` | Hero section | -| `Filter` | Filter component | -| `LanguageSelector` | Language selector | - ---- - -## Themes - -The UI Kit supports multiple predefined themes: - -### Available Themes - -| Theme | Description | -|-------|-------------| -| `default` | White Label - Light mode | -| `dark` | White Label - Dark mode | -| `dsfr-light` | French State Design System - Light mode | -| `dsfr-dark` | French State Design System - Dark mode | -| `anct-light` | ANCT - Light mode | -| `anct-dark` | ANCT - Dark mode | - -### Using Themes - -```tsx -import { CunninghamProvider, cunninghamConfig } from "@gouvfr-lasuite/ui-kit"; - -// Use a predefined theme - - - -``` - -### Creating a Custom Theme - -see [Cunningham docs](https://suitenumerique.github.io/cunningham/storybook/?path=/docs/getting-started-customization--docs) for more information - - - -## Hooks - -### useResponsive - -Detects responsive breakpoints: - -```tsx -import { useResponsive } from "@gouvfr-lasuite/ui-kit"; - -function MyComponent() { - const { isMobile, isTablet, isDesktop } = useResponsive(); - - return isDesktop ? : ; -} -``` - - - -### useCustomTranslations - -Override default translations: - -```tsx -import { useCustomTranslations } from "@gouvfr-lasuite/ui-kit"; - -// In your Provider -const customTranslations = { - "en-US": { - components: { - share: { - modalTitle: "Share this document", - }, - }, - }, -}; -``` - ---- - -## Internationalization - -The UI Kit natively supports French, English, Dutch, German and Spanish. - -### Supported Languages - -- `fr-FR` - French (default) -- `en-US` - English -- `nl-NL` - Dutch -- `de-DE` - German -- `es-ES` - Spanish - -### Changing Language - -```tsx - - - -``` - -### Contribute to add a language with a new PR - -- Create a new translation file in `src/locales` -- Add all translations -- Create a PR - -### Adding Custom Translations dynamically - -```tsx -import { locales } from "@gouvfr-lasuite/ui-kit"; - -const customLocales = { - ...locales, - "de-DE": { - components: { - share: { - modalTitle: "Teilen", - // ... - }, - }, - }, -}; - - - - -``` - - - ---- - -## Storybook Documentation - -Interactive documentation is available via Storybook. - -### Run Storybook Locally +Run the migration CLI without adding it to an application dependency list: ```bash -yarn storybook +npx @gouvfr-lasuite/ui-codemod@1.0.0 migrate . --source all ``` -Then access [http://localhost:6006](http://localhost:6006) - - - ---- +npx downloads the exact CLI version to the npm cache after confirmation and +does not modify the target project's `package.json`, lockfile or `node_modules`. ## Development -### Install Dependencies - -```bash -yarn install -``` - -### Available Scripts - -| Script | Description | -|--------|-------------| -| `yarn dev` | Start Vite development server | -| `yarn build` | Build library for production | -| `yarn lint` | Check code with ESLint | -| `yarn test` | Run tests with Vitest | -| `yarn storybook` | Start Storybook (port 6006) | -| `yarn build-storybook` | Build Storybook for deployment | -| `yarn build-theme` | Generate CSS/SCSS/TS token files | - -### Running Tests +Node 20 and Yarn 1.22.22 are required. ```bash -# Tests in watch mode +yarn install --frozen-lockfile +yarn build +yarn lint yarn test - -# Tests with coverage -yarn test --coverage -``` - -### Component Structure - -``` -src/components/my-component/ -├── index.tsx # Main export -├── index.scss # Component styles -├── MyComponent.tsx # React component -├── types.ts # TypeScript types -└── my-component.stories.tsx # Storybook stories -``` - ---- - -## Customization - -### Design Tokens - -Design tokens are defined in `cunningham.ts` and can be overridden: - -```tsx -// Available tokens -{ - globals: { - font: { sizes, weights, families }, - spacings: { xs, sm, md, lg, xl, ... }, - colors: { brand-*, gray-*, info-*, success-*, warning-*, error-* }, - breakpoints: { xxs, xs, mobile, tablet }, - }, - contextuals: { - background: { - semantic: { - brand: { - primary: '..' - } - } - } - } - -} -``` - -### CSS Variables - -Tokens are exposed as CSS variables: - -```css -.my-custom-element { - color: var(--c--globals--colors--brand-500); - padding: var(--c--globals--spacings--md); - font-size: var(--c--globals--font--sizes--lg); - background: var(--c--contextuals--background--semantic--brand--primary); -} -``` - -### Utility Classes - -```html - -
Primary text
-
Secondary background
- - -
Medium padding
-
Large margin
-``` - ---- - -## Usage Examples - -### Layout with TreeView and Search - -```tsx -import { - CunninghamProvider, - MainLayout, - TreeView, - QuickSearch, - UserMenu, -} from "@gouvfr-lasuite/ui-kit"; -import "@gouvfr-lasuite/ui-kit/style"; - -const treeData = [ - { - id: "1", - name: "Documents", - children: [ - { id: "1-1", name: "Report.pdf" }, - { id: "1-2", name: "Notes.md" }, - ], - }, -]; - -function App() { - return ( - - } - leftPanelContent={ - console.log(node)} - /> - } - rightHeaderContent={ - {}} - /> - } - > - console.log(query)} - groups={[ - { - title: "Recent documents", - items: [ - { id: "1", label: "Q4 Report" }, - { id: "2", label: "Budget 2024" }, - ], - }, - ]} - /> -
Main content
-
-
- ); -} -``` - -### Share Modal - -```tsx -import { ShareModal } from "@gouvfr-lasuite/ui-kit"; - -function ShareButton() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - setIsOpen(false)} - members={[ - { id: "1", name: "Marie", email: "marie@example.com", role: "editor" }, - ]} - onAddMember={(user, role) => {}} - onRemoveMember={(userId) => {}} - onCopyLink={() => navigator.clipboard.writeText(window.location.href)} - /> - - ); -} -``` - -### Form with Validation - -```tsx -import { Input, Select, Switch, Label } from "@gouvfr-lasuite/ui-kit"; - -function ContactForm() { - return ( -
-
- - -
- -
- - { + setValue(e.target.checked); + props.onChange?.(e); + }} + checked={value} + ref={(checkboxRef) => { + if (typeof ref === "function") { + ref(checkboxRef); + } + inputRef.current = checkboxRef || null; + }} + /> + + +
+ {label &&
{label}
} + + + + ); +}; + +export const CheckboxGroup = ({ + children, + className, + ...props +}: PropsWithChildren & FieldProps) => { + return ( + +
{children}
+
+ ); +}; + +const Checkmark = () => ( + e.stopPropagation()} + > + + +); + +const Indeterminate = () => ( + e.stopPropagation()} + > + + +); diff --git a/packages/ui-components/src/components/Forms/Checkbox/resources/dd_1.svg b/packages/ui-components/src/components/Forms/Checkbox/resources/dd_1.svg new file mode 100644 index 00000000..4742c73f --- /dev/null +++ b/packages/ui-components/src/components/Forms/Checkbox/resources/dd_1.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui-components/src/components/Forms/Checkbox/resources/dd_2.svg b/packages/ui-components/src/components/Forms/Checkbox/resources/dd_2.svg new file mode 100644 index 00000000..6f8e2a66 --- /dev/null +++ b/packages/ui-components/src/components/Forms/Checkbox/resources/dd_2.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui-components/src/components/Forms/Checkbox/tokens.ts b/packages/ui-components/src/components/Forms/Checkbox/tokens.ts new file mode 100644 index 00000000..14ee48e8 --- /dev/null +++ b/packages/ui-components/src/components/Forms/Checkbox/tokens.ts @@ -0,0 +1,22 @@ +import { DefaultTokens } from "@gouvfr-lasuite/ui-tokens"; + +export const tokens = (defaults: DefaultTokens) => ({ + "background-color--hover": + defaults.contextuals.background.semantic.neutral["tertiary-hover"], + "background-color": defaults.contextuals.background.surface.primary, + "font-size": defaults.globals.font.sizes.s, + "font-weight": defaults.globals.font.weights.medium, + color: defaults.contextuals.content.semantic.neutral.primary, + "border-color": defaults.contextuals.content.semantic.neutral.tertiary, + "border-color--hover": defaults.contextuals.content.semantic.neutral.tertiary, + "border-color--focus": defaults.contextuals.content.semantic.brand.primary, + "border-radius": "2px", + "border-width": "1.5px", + "border-width--hover": "1px", + "accent-color": defaults.contextuals.content.semantic.brand.tertiary, + "accent-color--disabled": + defaults.contextuals.content.semantic.disabled.primary, + "checkmark-color": defaults.contextuals.content.semantic.contextual.primary, + + size: "1.5rem", +}); diff --git a/packages/ui-components/src/components/Forms/ClassicLabel/index.tsx b/packages/ui-components/src/components/Forms/ClassicLabel/index.tsx new file mode 100644 index 00000000..0c8bb3eb --- /dev/null +++ b/packages/ui-components/src/components/Forms/ClassicLabel/index.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import classNames from "classnames"; + +export interface ClassicLabelProps { + label?: string; + hideLabel?: boolean; + disabled?: boolean; + className?: string; + disabledClassName?: string; + htmlFor?: string; + id?: string; + onClick?: () => void; +} + +/** + * Renders a label for the "classic" field variant. + * - When hideLabel is false: renders a visible label with the given className. + * - When hideLabel is true: renders an offscreen label for accessibility. + * - When label is falsy: renders nothing. + */ +export const ClassicLabel = ({ + label, + hideLabel, + disabled, + className, + disabledClassName, + htmlFor, + id, + onClick, +}: ClassicLabelProps) => { + if (!label) { + return null; + } + + if (hideLabel) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/ui-components/src/components/Forms/DatePicker/DateField.tsx b/packages/ui-components/src/components/Forms/DatePicker/DateField.tsx new file mode 100644 index 00000000..5fa3d7e0 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DateField.tsx @@ -0,0 +1,91 @@ +import React, { useRef } from "react"; +import { + AriaDatePickerProps, + useDateField, + useDateSegment, +} from "@react-aria/datepicker"; +import { useLocale } from "@react-aria/i18n"; +import { + DateFieldState, + useDateFieldState, + DateSegment, +} from "@react-stately/datepicker"; +import { createCalendar, DateValue } from "@internationalized/date"; +import classNames from "classnames"; +import { LabelledBox, Props } from ":/components/Forms/LabelledBox"; +import type { FieldVariant } from ":/components/Forms/types"; + +interface DateSegmentProps { + currentSegment: DateSegment; + previousSegment: DateSegment; + state: DateFieldState; +} + +const DateSegmentInput = ({ + currentSegment, + previousSegment, + state, +}: DateSegmentProps) => { + const ref = useRef(null); + const { segmentProps } = useDateSegment(currentSegment, state, ref); + return ( + + {currentSegment.text?.toUpperCase()} + + ); +}; + +const DateField = (props: AriaDatePickerProps) => { + const { locale } = useLocale(); + const state = useDateFieldState({ + ...props, + locale, + createCalendar, + }); + const ref = useRef(null); + const { fieldProps } = useDateField(props, state, ref); + + return ( +
+ {state.segments.map((segment, i, segments) => ( + + ))} +
+ ); +}; + +interface DateFieldBoxProps + extends Props, + Omit, "label"> { + variant?: FieldVariant; +} + +const DateFieldBox = ({ + variant = "floating", + ...props +}: DateFieldBoxProps) => ( + +
+ +
+
+); + +export default DateFieldBox; diff --git a/packages/ui-components/src/components/Forms/DatePicker/DatePicker.spec.tsx b/packages/ui-components/src/components/Forms/DatePicker/DatePicker.spec.tsx new file mode 100644 index 00000000..adb610af --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DatePicker.spec.tsx @@ -0,0 +1,1540 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen, within } from "@testing-library/react"; +import React, { FormEvent, useState } from "react"; +import { expect, vi } from "vitest"; +import { CunninghamProvider } from ":/components/Provider"; +import { DatePicker } from ":/components/Forms/DatePicker/DatePicker"; +import { Button } from ":/components/Button"; + +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@internationalized/date", + ); + return { + ...mod, + // Note: Restoring mocks will cause the function to return 'undefined'. + // Consider providing a default implementation to be restored instead. + getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"), + }; +}); + +describe("", () => { + const expectCalendarToBeClosed = () => { + expect(screen.queryByRole("application")).toBeNull(); + }; + + const expectCalendarToBeOpen = () => { + const calendar = screen.queryByRole("application"); + expect(calendar).toBeDefined(); + expect(calendar).not.toBeNull(); + expect(Array.from(calendar!.classList)).contains( + "c__calendar__wrapper--opened", + ); + }; + + const expectDateFieldToBeHidden = () => { + const dateField = screen.queryByRole("presentation"); + expect(dateField).toBeTruthy(); + expect(Array.from(dateField!.parentElement!.classList)).contains( + "c__date-picker__inner--collapsed", + ); + }; + + const expectDateFieldToBeDisplayed = () => { + expect(screen.queryByRole("presentation")).toBeTruthy(); + }; + + const expectDatesToBeEqual = ( + firstDate: Date | string | undefined | null, + secondDate: Date | string | undefined | null, + ) => { + expect(firstDate).toBeDefined(); + expect(secondDate).toBeDefined(); + expect(new Date(firstDate!).toLocaleDateString()).eq( + new Date(secondDate!).toLocaleDateString(), + ); + }; + + const expectFocusedMonthToBeEqual = (expectedMonth: string) => { + const focusedMonth = screen + .getByRole("combobox", { + name: "Select a month", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedMonth).eq(expectedMonth); + }; + + const expectFocusedYearToBeEqual = (expectedYear: string) => { + const focusedYear = screen + .getByRole("combobox", { + name: "Select a year", + })! + .textContent?.replace("arrow_drop_down", ""); + + expect(focusedYear).eq(expectedYear); + }; + + const expectMenuToBeOpen = (menu: HTMLElement) => { + expect(Array.from(menu.classList)).contains("c__calendar__menu--opened"); + }; + + const expectMenuToBeClosed = (menu: HTMLElement) => { + expect(Array.from(menu.classList)).not.contains( + "c__calendar__menu--opened", + ); + }; + + const expectDatePickerStateToBe = async (state: string) => { + const input = (await screen.findAllByRole("button"))![0]; + const classNames = + input.parentElement && Array.from(input.parentElement.classList); + expect(classNames).contains(`c__date-picker--${state}`); + }; + + it("toggles calendar", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const [input, button] = await screen.findAllByRole("button"); + + // It returns the clickable div. + expect(input.tagName).toEqual("DIV"); + + // It returns the toggle button. + expect(button.tagName).toEqual("BUTTON"); + + // Calendar is initially closed. + expectCalendarToBeClosed(); + expectDateFieldToBeHidden(); + + // Toggle button opens the calendar. + await user.click(button); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // Clicking again closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + expectDateFieldToBeHidden(); + + // Click on the input opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // Clicking again on the input should not close the calendar. + // Click could happen while selecting the date field input. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // While the calendar open, clicking on + // the toggle button closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + }); + + it("toggles calendar with keyboard", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [input, toggleButton] = await screen.findAllByRole("button")!; + + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Enter}"); + expectCalendarToBeOpen(); + }); + + it("focuses in the right order with no picked date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + // Get elements that should receive focus when no date is picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + const [monthSegment, daySegment, yearSegment] = + await screen.findAllByRole("spinbutton"); + + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(daySegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearSegment).toHaveFocus(); + }); + + it("focuses in the right order with a default value", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Get elements that should receive focus when a date is already picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + const [monthSegment, daySegment, yearSegment] = + await screen.findAllByRole("spinbutton")!; + + // Navigate through elements using Tab. + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(daySegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(clearButton).toHaveFocus(); + }); + + it("picks a date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [input, toggleButton] = await screen.findAllByRole("button"); + await user.click(input); + + // Select all grid-cells. + const gridCells = await screen.findAllByRole("gridcell"); + + // Select the first clickable grid-cell. + const gridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled"), + )![0], + ).getByRole("button")!; + + // Pick a date. + await user.click(gridCellButton); + const gridCellDate = gridCellButton.getAttribute("aria-label"); + + // Calendar should close on date's selection. + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Reopen the calendar. + await user.click(toggleButton); + + // Get the selected date in calendar grid. + const selectedDate = within( + screen.getByRole("gridcell", { + selected: true, + })!, + ) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", ""); + + expectDatesToBeEqual(selectedDate, gridCellDate); + + // Get the selected date in the date field. + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual(dateFieldContent, gridCellDate); + }); + + it("type a date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [monthSegment, daySegment, yearSegment] = + await screen.findAllByRole("spinbutton")!; + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type month's value. + await user.keyboard("{1}{2}"); + expect(daySegment).toHaveFocus(); + + // Type day's value. + await user.keyboard("{5}"); + expect(yearSegment).toHaveFocus(); + + // Type year's value. + await user.keyboard("{2}{0}{2}{3}"); + + // Check date field value. + expectDateFieldToBeDisplayed(); + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual("2023-12-05", dateFieldContent); + }); + + it("type an invalid date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [monthSegment, daySegment, yearSegment] = + await screen.findAllByRole("spinbutton")!; + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type month's value. + await user.keyboard("{1}{2}"); + expect(daySegment).toHaveFocus(); + + // Type day's value. + await user.keyboard("{5}"); + expect(yearSegment).toHaveFocus(); + + // Type year's value. + await user.keyboard("{2}{0}{2}{1}"); + + // Check date field value. + expectDateFieldToBeDisplayed(); + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual("2021-12-05", dateFieldContent); + + // Make sure the date picker is showing error. + expectDatePickerStateToBe("invalid"); + }); + + it.each([ + "2022-05-25T23:59:59.000Z", + "2023-06-01T00:00:00.000Z", + "2025-03-25T12:30:00.000Z", + ])("has a default value", async (defaultValue) => { + render( + + + , + ); + // Get picked date. + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual(defaultValue, dateFieldContent); + expectDateFieldToBeDisplayed(); + }); + + it("has an uncontrolled a controlled value", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + , + ), + ).toThrow( + "You cannot use both defaultValue and value props on DatePicker component", + ); + }); + + it.each(["this_not_a_valid_date", "2023-13-13", "2025-25-05"])( + "has not a valid date value", + async (invalidDate) => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + , + ), + ).toThrow(/Failed to parse date value:/); + }, + ); + + it("clears date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectCalendarToBeOpen(); + + // Date field's value should be set to a placeholder value. + const dateFieldContent = screen.getByRole("presentation").textContent; + expect(dateFieldContent).eq("MM/DD/YYYY"); + + const isGridCellSelected = screen + .getByRole("gridcell", { name: "24" })! + .getAttribute("aria-selected"); + expect(isGridCellSelected).toBeNull(); + + // Close the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Make sure the empty date field is hidden when closing the calendar. + expectDateFieldToBeHidden(); + }); + + it("has date inferior to minValue", async () => { + render( + + + , + ); + await expectDatePickerStateToBe("invalid"); + }); + + it("has date superior to maxValue", async () => { + render( + + + , + ); + await expectDatePickerStateToBe("invalid"); + }); + + it("has an invalid value outside min/max range", async () => { + // Should be invalid if the picked date is outside the min/max range. + render( + + + , + ); + await expectDatePickerStateToBe("invalid"); + }); + + it("works controlled", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState( + "2023-04-25T00:00:00.000Z", + ); + return ( + +
+
Value = {value}|
+ + setValue(e)} + /> +
+
+ ); + }; + render(); + + // Make sure value is selected. + screen.getByText("Value = 2023-04-25T00:00:00.000Z|"); + + // Make sure value is initially render in the date field component. + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual("2023-04-25T00:00:00.000Z", dateFieldContent); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const gridCell = within( + screen.getByRole("gridcell", { name: "12" }), + ).getByRole("button")!; + + // Select a new value in the calendar grid. + await user.click(gridCell); + expectCalendarToBeClosed(); + + // Make sure value is selected. + screen.getByText(`Value = 2023-04-12T00:00:00.000Z|`); + + // Clear value. + const clearButton = screen.getByRole("button", { + name: "Clear", + }); + await user.click(clearButton); + + // Make sure value is cleared. + expectDateFieldToBeHidden(); + screen.getByText("Value = |"); + }); + + it("has a timezone", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState(null); + return ( + +
+
Value = {value}|
+ + setValue(e)} + timezone="America/Sao_Paulo" + /> +
+
+ ); + }; + render(); + + // Make sure any value is selected. + screen.getByText("Value = |"); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const monthSegment = await screen.getByRole("spinbutton", { + name: /month/, + }); + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Make sure value is selected at midnight on America/Sao_Paulo. + screen.getByText(`Value = 2023-05-12T03:00:00.000Z|`); + }); + + it("renders disabled", async () => { + render( + + + , + ); + await expectDatePickerStateToBe("disabled"); + + const [input, button] = await screen.findAllByRole("button"); + + // Make sure toggle button and click on input are disabled. + expect(input.getAttribute("aria-disabled")).eq("true"); + expect(button).toBeDisabled(); + + // Make sure the clear button is not visible and disabled. + expect( + screen.queryByRole("button", { name: "Clear date", hidden: true }), + ).toBeDisabled(); + + // Make sure each segment of the date field is disabled. + const dateFieldInputs = await screen.queryAllByRole("spinbutton"); + dateFieldInputs.forEach((dateFieldInput) => + expect(dateFieldInput).toHaveAttribute("aria-disabled"), + ); + }); + + it("renders focused", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + await expectDatePickerStateToBe("focused"); + }); + + it("submits forms data", async () => { + let formData: any; + const Wrapper = () => { + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + formData = { + datepicker: data.get("datepicker"), + }; + }; + + return ( + +
+ + + + +
+
+ ); + }; + render(); + + const user = userEvent.setup(); + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + + // Submit the form being empty. + await user.click(submitButton); + expect(formData).toEqual({ + datepicker: "", + }); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + const monthSegment = await screen.getByRole("spinbutton", { + name: /month/, + }); + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Make sure form's value matches. + // It should be equal the 12th of May at midnight in local timezone. + expect(formData).toEqual({ + datepicker: "2023-05-11T22:00:00.000Z", + }); + + // Clear picked date. + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectDateFieldToBeDisplayed(); + + // Submit the form being empty. + await user.click(submitButton); + + // Date field disappears when the user click outside the component. + expectDateFieldToBeHidden(); + + // Make sure form's value is null. + expect(formData).toEqual({ + datepicker: "", + }); + }); + + it("submits forms data with a default value", async () => { + let formData: any; + const Wrapper = () => { + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + formData = { + datepicker: data.get("datepicker"), + }; + }; + + return ( + +
+
+ + + +
+
+ ); + }; + render(); + + const user = userEvent.setup(); + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + + // Submit the form with the default value. + // Time should be equal to the initial one in UTC. + await user.click(submitButton); + expect(formData).toEqual({ + datepicker: "2023-04-25T12:00:00.000Z", + }); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + const monthSegment = await screen.getByRole("spinbutton", { + name: /month/, + }); + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Make sure form's value matches. + // Selection should keep the default time passed to the component. + // Thus, component output should still be at noon UTC. + expect(formData).toEqual({ + datepicker: "2023-05-12T12:00:00.000Z", + }); + + // Clear picked date. + // We lose info about the initial time. + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectDateFieldToBeDisplayed(); + + // Submit the form being empty. + await user.click(submitButton); + + // Date field disappears when the user click outside the component. + expectDateFieldToBeHidden(); + + // Make sure form's value is null. + expect(formData).toEqual({ + datepicker: "", + }); + + // Select the first segment, month one. + await user.click(monthSegment); + expect(monthSegment).toHaveFocus(); + + // Type a new date's value, that would be selected at midnight local timezone. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldToBeDisplayed(); + + // Make sure form's value matches. + // It should be equal the 2023-05-12 at midnight in local timezone. + expect(formData).toEqual({ + datepicker: "2023-05-11T22:00:00.000Z", + }); + }); + + it("clicks next and previous focused month", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Focused month of the calendar grid should be the one from the default value. + expectFocusedMonthToBeEqual("May"); + + const previousMonthButton = screen.getByRole("button", { + name: "Previous month", + }); + const nextMonthButton = screen.getByRole("button", { + name: "Next month", + }); + + // Focus previous month. + await user.click(previousMonthButton); + expectFocusedMonthToBeEqual("April"); + + // Get back to the default focused month. + await user.click(nextMonthButton); + expectFocusedMonthToBeEqual("May"); + + // Focus next month. + await user.click(nextMonthButton); + expectFocusedMonthToBeEqual("June"); + }); + + it("changes focused year via dropdown", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Focused year of the calendar grid should be the one from the default value. + expectFocusedYearToBeEqual("2023"); + + // Use year dropdown to navigate to 2022. + const yearButton = screen.getByRole("combobox", { + name: "Select a year", + }); + await user.click(yearButton); + await user.click(screen.getByRole("option", { name: "2022" })); + expectFocusedYearToBeEqual("2022"); + + // Use year dropdown to navigate to 2024. + await user.click(yearButton); + await user.click(screen.getByRole("option", { name: "2024" })); + expectFocusedYearToBeEqual("2024"); + }); + + it("renders disabled next and previous month", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // minValue doesn't allow to navigate to previous month. + const previousMonthButton = screen.getByRole("button", { + name: "Previous month", + }); + expect(previousMonthButton).toBeDisabled(); + + // maxValue doesn't allow to navigate to previous month. + const nextMonthButton = screen.getByRole("button", { + name: "Next month", + }); + expect(nextMonthButton).toBeDisabled(); + }); + + it("renders disabled year options in dropdown when constrained", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Year dropdown should show 2023 as selected, with other years disabled. + const yearButton = screen.getByRole("combobox", { + name: "Select a year", + }); + await user.click(yearButton); + + // 2022 should be disabled since minValue is in 2023. + const option2022 = screen.getByRole("option", { name: "2022" }); + expect( + option2022.classList.contains("c__calendar__menu__item--disabled"), + ).toBe(true); + + // 2024 should be disabled since maxValue is in 2023. + const option2024 = screen.getByRole("option", { name: "2024" }); + expect( + option2024.classList.contains("c__calendar__menu__item--disabled"), + ).toBe(true); + }); + + it("renders partially disabled next and previous month", async () => { + const user = userEvent.setup(); + const minValue = new Date("2023-04-23T00:00:00.000z"); + const maxValue = new Date("2023-06-23T00:00:00.000z"); + render( + + + , + ); + + // Open the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + const previousMonthButton = screen.getByRole("button", { + name: "Previous month", + }); + const nextMonthButton = screen.getByRole("button", { + name: "Next month", + }); + + // Previous and next month buttons should not be disabled. + // Some days in previous and next month can be selected. + expect(previousMonthButton).not.toBeDisabled(); + expect(nextMonthButton).not.toBeDisabled(); + + // Month n-2 should not be accessible, + // as month n-1 is partially disabled, + // all days from month n-2 are disabled. + await user.click(previousMonthButton); + expect(previousMonthButton).toBeDisabled(); + expect(nextMonthButton).not.toBeDisabled(); + + // Get all the calendar's cells to check their state. + let gridCells = await screen.findAllByRole("gridcell"); + + // By default, calendar's cells outside the focused month are not visible (month n-1 and n+1). + // Cells within the focused month superior to the minDate should be clickable. + // Cells inferior to the minDate, within the month, should be disabled. + gridCells.forEach((gridCell) => { + try { + const button = within(gridCell).getByRole("button")!; + const value = new Date( + button + .getAttribute("aria-label")! + .replace("First available date", ""), + ); + expect(value.getMonth() === minValue.getMonth()); + if (value.getDate() < minValue.getDate()) { + expect(button).toBeDisabled(); + } else { + expect(button).not.toBeDisabled(); + } + } catch (e: any) { + // Make sure outside grid-cells render any button element, even disabled. + expect(e.message).contains( + 'Unable to find an accessible element with the role "button"', + ); + } + }); + + await user.click(nextMonthButton); + expect(previousMonthButton).not.toBeDisabled(); + expect(nextMonthButton).not.toBeDisabled(); + + await user.click(nextMonthButton); + expect(previousMonthButton).not.toBeDisabled(); + expect(nextMonthButton).toBeDisabled(); + + gridCells = await screen.findAllByRole("gridcell"); + gridCells.forEach((gridCell) => { + try { + const button = within(gridCell).getByRole("button")!; + const buttonLabel = button + .getAttribute("aria-label") + ?.replace("Last available date", ""); + expect(buttonLabel).toBeDefined(); + const value = new Date(buttonLabel!); + expect(value.getMonth() === minValue.getMonth()); + if (value.getDate() > maxValue.getDate()) { + expect(button).toBeDisabled(); + } else { + expect(button).not.toBeDisabled(); + } + } catch (e: any) { + // Make sure outside grid-cells render any button element, even disabled. + expect(e.message).contains( + 'Unable to find an accessible element with the role "button"', + ); + } + }); + }); + + it("selects a focused month", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Get month dropdown. + const monthDropdown = screen.getByRole("combobox", { + name: "Select a month", + }); + + const [monthMenu, yearMenu] = await screen.findAllByRole("listbox"); + + expectMenuToBeClosed(monthMenu); + expectMenuToBeClosed(yearMenu); + + // Make sure the selected item matched the default value. + let focusedMonth = monthDropdown.textContent?.replace( + "arrow_drop_down", + "", + ); + expect(focusedMonth).eq("May"); + + // Open month dropdown. + await user.click(monthDropdown); + expectMenuToBeOpen(monthMenu); + expectMenuToBeClosed(yearMenu); + + // Select a month option. + const option: HTMLLIElement = screen.getByRole("option", { + name: "August", + }); + await user.click(option); + + expectMenuToBeClosed(monthMenu); + expectMenuToBeClosed(yearMenu); + + // Make sure focused month has properly updated. + focusedMonth = monthDropdown.textContent?.replace("arrow_drop_down", ""); + expect(focusedMonth).eq("August"); + }); + + it("selects a focused year", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Get year dropdown. + const yearDropdown = screen.getByRole("combobox", { + name: "Select a year", + }); + + const [monthMenu, yearMenu] = await screen.findAllByRole("listbox"); + + expectMenuToBeClosed(yearMenu); + expectMenuToBeClosed(monthMenu); + + // Make sure the selected item matched the default value. + let focusedYear = yearDropdown.textContent?.replace("arrow_drop_down", ""); + expect(focusedYear).eq("2023"); + + await user.click(yearDropdown); + expectMenuToBeOpen(yearMenu); + expectMenuToBeClosed(monthMenu); + + // Select a year option. + const option: HTMLLIElement = screen.getByRole("option", { + name: "2024", + }); + await user.click(option); + + expectMenuToBeClosed(yearMenu); + expectMenuToBeClosed(monthMenu); + + // Make sure focused month has properly updated. + focusedYear = yearDropdown.textContent?.replace("arrow_drop_down", ""); + expect(focusedYear).eq("2024"); + }); + + it("renders only cell within the focused month", async () => { + const user = userEvent.setup(); + const defaultValue = new Date("2023-05-23T00:00:00.000Z"); + render( + + + , + ); + // Open the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Get all grid-cells from the calendar. + const gridCells = await screen.findAllByRole("gridcell"); + let visibleValues: Array = []; + // Make sure that all visible grid-cells are within the focused month. + gridCells.forEach((gridCell) => { + try { + const button = within(gridCell).getByRole("button"); + const value = new Date( + button.getAttribute("aria-label")!.replace("selected", ""), + ); + expect(button).not.toBeDisabled(); + expect(defaultValue.getMonth() === value.getMonth()); + visibleValues = [value, ...visibleValues]; + } catch (e: any) { + // Make sure outside grid-cells render any button element, even disabled. + expect(e.message).contains( + 'Unable to find an accessible element with the role "button"', + ); + } + }); + // Make sure we get as many visible values as days in the focused month. + expect(visibleValues.length).eq(31); + }); + + it("navigate previous focused month with keyboard", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Open calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Navigate to the previous month using keyboard. + // default value is the cell of the calendar grid. + // Thus, navigating one cell up, changes the focused month. + await user.keyboard("{ArrowUp}"); + expectFocusedMonthToBeEqual("April"); + + // Reopen the calendar menu to reset the focused month. + await user.click(toggleButton); + await user.click(toggleButton); + + expectFocusedMonthToBeEqual("May"); + + await user.keyboard("{ArrowLeft}"); + expectFocusedMonthToBeEqual("April"); + }); + + it("navigate next focused month with keyboard", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + await user.keyboard("{ArrowDown}"); + expectFocusedMonthToBeEqual("June"); + + // Reopen the calendar menu to reset the focused month. + await user.click(toggleButton); + await user.click(toggleButton); + + expectFocusedMonthToBeEqual("May"); + + // Navigate to the previous month using keyboard. + // default value is the last cell of the calendar grid. + // Thus, navigating one cell right, changes the focused month. + await user.keyboard("{ArrowRight}"); + expectFocusedMonthToBeEqual("June"); + }); + + it("uses the locale props calendar system", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = (await screen.findAllByRole("button"))[0]; + + // Toggle button opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // Make sure dateField is in the right locale + const dateFieldContent = screen.getByRole("presentation").textContent; + expect(dateFieldContent).eq("4/4/1945 शक"); + + // Make sure month is in the right locale + const focusedMonth = screen + .getByRole("combobox", { + name: "Select a month", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedMonth).eq("आषाढ़"); + + // Make sure year is in the right locale + const focusedYear = screen + .getByRole("combobox", { + name: "Select a year", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedYear).eq("1945 शक"); + + // Make sure weekdays are in the right locale + screen.getByText("रवि"); + screen.getByText("सोम"); + screen.getByText("मंगल"); + screen.getByText("बुध"); + screen.getByText("गुरु"); + screen.getByText("शुक्र"); + screen.getByText("शनि"); + }); + + it("uses the cunningham provider props calendar systems", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = (await screen.findAllByRole("button"))[0]; + + // Toggle button opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // Make sure dateField is in the right locale + const dateFieldContent = screen.getByRole("presentation").textContent; + expect(dateFieldContent).eq("25/06/2023"); + + // Make sure month is in the right locale + const focusedMonth = screen + .getByRole("combobox", { + name: "Sélectionner un mois", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedMonth).eq("juin"); + + // Make sure year is in the right locale + const focusedYear = screen + .getByRole("combobox", { + name: "Sélectionner une année", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedYear).eq("2023"); + + // Make sure weekdays are in the right locale + screen.getByText("lun."); + screen.getByText("mar."); + screen.getByText("mer."); + screen.getByText("jeu."); + screen.getByText("ven."); + screen.getByText("sam."); + screen.getByText("dim."); + }); + + it("makes sure the locale props override the cunningham provider calendar system", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const input = (await screen.findAllByRole("button"))[0]; + + // Toggle button opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldToBeDisplayed(); + + // Make sure dateField is in the right locale + const dateFieldContent = screen.getByRole("presentation").textContent; + expect(dateFieldContent).eq("4/4/1945 शक"); + + // Make sure month is in the right locale + // And aria-label uses the right translation. + const focusedMonth = screen + .getByRole("combobox", { + name: "Sélectionner un mois", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedMonth).eq("आषाढ़"); + + // Make sure year is in the right locale + // And aria-label uses the right translation. + const focusedYear = screen + .getByRole("combobox", { + name: "Sélectionner une année", + })! + .textContent?.replace("arrow_drop_down", ""); + expect(focusedYear).eq("1945 शक"); + + // Make sure weekdays are in the right locale + screen.getByText("रवि"); + screen.getByText("सोम"); + screen.getByText("मंगल"); + screen.getByText("बुध"); + screen.getByText("गुरु"); + screen.getByText("शुक्र"); + screen.getByText("शनि"); + }); + + it("has a wrong locale props", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + , + ), + ).toThrow("Incorrect locale information provided"); + }); + + it("keeps time component while selecting a date", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState( + "2023-04-25T12:00:00.000Z", + ); + return ( + +
+
Value = {value}|
+ + setValue(e)} + /> +
+
+ ); + }; + render(); + + // Make sure initial value is printed. + screen.getByText("Value = 2023-04-25T12:00:00.000Z|"); + + // Make sure value is initially rendered in the date field component. + const dateFieldContent = screen.getByRole("presentation").textContent; + expectDatesToBeEqual("2023-04-25", dateFieldContent); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const gridCell = within( + screen.getByRole("gridcell", { name: "12" }), + ).getByRole("button")!; + + // Select a new value in the calendar grid. + await user.click(gridCell); + expectCalendarToBeClosed(); + + // Make sure value is selected, with the same time as the initial value. + screen.getByText(`Value = 2023-04-12T12:00:00.000Z|`); + }); + + it("renders with className", async () => { + render( + + + , + ); + expect( + document.querySelector(".c__field.my-custom-class"), + ).toBeInTheDocument(); + }); + + describe("classic variant", () => { + it("renders with classic variant", async () => { + render( + + + , + ); + // In classic mode, label is rendered outside the wrapper with its own class + expect( + document.querySelector(".c__date-picker__label"), + ).toBeInTheDocument(); + + expect( + document.querySelector(".c__date-picker--classic"), + ).toBeInTheDocument(); + }); + + it("label is always static in classic variant", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const label = screen.getByText("Pick a date"); + + // In classic variant, label is outside the wrapper with c__date-picker__label class + expect(label.classList.contains("c__date-picker__label")).toBe(true); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Label should still have the same class + expect(label.classList.contains("c__date-picker__label")).toBe(true); + }); + + it("defaults to floating variant", () => { + render( + + + , + ); + expect( + document.querySelector(".c__date-picker--classic"), + ).not.toBeInTheDocument(); + }); + }); + + describe("hideLabel", () => { + it("hides label visually but keeps it accessible in floating variant", () => { + render( + + + , + ); + // Label should be visually hidden via LabelledBox + const label = screen.getByText("Pick a date"); + expect(label.closest("label")).toHaveClass("c__offscreen"); + }); + + it("hides label visually but keeps it accessible in classic variant", () => { + render( + + + , + ); + // Label should be visually hidden with c__offscreen class + const label = screen.getByText("Pick a date"); + expect(label).toHaveClass("c__offscreen"); + // The visible label class should not be present + expect( + document.querySelector(".c__date-picker__label:not(.c__offscreen)"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui-components/src/components/Forms/DatePicker/DatePicker.tsx b/packages/ui-components/src/components/Forms/DatePicker/DatePicker.tsx new file mode 100644 index 00000000..d5d2cd82 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DatePicker.tsx @@ -0,0 +1,90 @@ +import React, { useMemo, useRef, useState } from "react"; +import { + DatePickerStateOptions, + useDatePickerState, +} from "@react-stately/datepicker"; +import { DateValue } from "@internationalized/date"; +import { useDatePicker } from "@react-aria/datepicker"; +import DatePickerAux, { + DatePickerAuxSubProps, +} from ":/components/Forms/DatePicker/DatePickerAux"; +import { Calendar } from ":/components/Calendar/CalendarAux"; +import DateFieldBox from ":/components/Forms/DatePicker/DateField"; +import { + convertDateValueToString, + getDefaultPickerOptions, + parseDateValue, +} from ":/components/Forms/DatePicker/utils"; + +export type DatePickerProps = DatePickerAuxSubProps & { + value?: null | string; + label: string; + defaultValue?: string; + onChange?: (value: string | null) => void | undefined; +}; + +export const DatePicker = (props: DatePickerProps) => { + if (props.defaultValue && props.value) { + throw new Error( + "You cannot use both defaultValue and value props on DatePicker component", + ); + } + const ref = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const options: DatePickerStateOptions = { + ...getDefaultPickerOptions(props), + value: + // Force clear the component's value when passing null or an empty string. + props.value === "" || props.value === null + ? null + : parseDateValue(props.value, props.timezone), + defaultValue: parseDateValue(props.defaultValue, props.timezone), + onChange: (value: DateValue | null) => { + props.onChange?.(convertDateValueToString(value, props.timezone)); + }, + }; + const pickerState = useDatePickerState(options); + const { fieldProps, calendarProps, ...pickerProps } = useDatePicker( + options, + pickerState, + ref, + ); + + const isClassic = props.variant === "classic"; + const labelAsPlaceholder = useMemo( + () => !isFocused && !pickerState.isOpen && !pickerState.value, + [pickerState.value, pickerState.isOpen, isFocused], + ); + + const calendar = ; + + return ( + pickerState.setValue(null as unknown as DateValue), + }} + ref={ref} + > + + + ); +}; diff --git a/packages/ui-components/src/components/Forms/DatePicker/DatePickerAux.tsx b/packages/ui-components/src/components/Forms/DatePicker/DatePickerAux.tsx new file mode 100644 index 00000000..e9728b94 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DatePickerAux.tsx @@ -0,0 +1,289 @@ +import React, { + PropsWithChildren, + useMemo, + useRef, + RefAttributes, +} from "react"; +import { + DateRangePickerState, + DatePickerState, +} from "@react-stately/datepicker"; +import { DateRangePickerAria, DatePickerAria } from "@react-aria/datepicker"; +import classNames from "classnames"; +import { I18nProvider } from "@react-aria/i18n"; +import { Button } from ":/components/Button"; +import { Popover } from ":/components/Popover"; +import { Field, FieldProps } from ":/components/Forms/Field"; +import { ClassicLabel } from ":/components/Forms/ClassicLabel"; +import { useCunningham } from ":/components/Provider"; +import { Calendar, CalendarRange } from ":/components/Calendar"; +import { convertDateValueToString } from ":/components/Forms/DatePicker/utils"; +import type { FieldVariant } from ":/components/Forms/types"; + +export type DatePickerAuxSubProps = FieldProps & { + label?: string; + variant?: FieldVariant; + hideLabel?: boolean; + minValue?: string; + maxValue?: string; + disabled?: boolean; + name?: string; + locale?: string; + timezone?: string; +}; + +export type DatePickerAuxProps = PropsWithChildren & + DatePickerAuxSubProps & + RefAttributes & { + pickerState: DateRangePickerState | DatePickerState; + pickerProps: Pick< + DateRangePickerAria | DatePickerAria, + "buttonProps" | "groupProps" + >; + calendar: React.ReactElement; + isFocused: boolean; + labelAsPlaceholder: boolean; + optionalClassName?: string; + isRange?: boolean; + onClear: () => void; + // For classic range mode: render labels above the wrapper + rangeLabels?: { + startLabel: string; + endLabel: string; + disabled?: boolean; + hideLabel?: boolean; + }; + }; + +/** + * This component is used by date and date range picker components. + * It contains the common logic between the two. + */ +const DatePickerAux = ({ + className, + pickerState, + pickerProps, + onClear, + isFocused, + labelAsPlaceholder, + calendar, + children, + name, + locale, + disabled = false, + optionalClassName, + isRange, + rangeLabels, + ref, + ...props +}: DatePickerAuxProps) => { + const { t, currentLocale } = useCunningham(); + const pickerRef = useRef(null); + const wrapperRef = useRef(null); + + const isDateInvalid = useMemo( + () => pickerState.validationState === "invalid" || props.state === "error", + [pickerState.validationState, props.state], + ); + + const isClassic = props.variant === "classic"; + + return ( + + +
+ {isClassic && !isRange && ( + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + )} + {/* Classic variant: range labels above the wrapper */} + {isClassic && rangeLabels && !rangeLabels.hideLabel && ( +
+ { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> +
+ { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> +
+ )} + {/* Hidden range labels for accessibility when hideLabel is true */} + {isClassic && rangeLabels && rangeLabels.hideLabel && ( + <> + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + { + wrapperRef.current?.focus(); + if (!pickerState.isOpen) { + pickerState.open(); + } + }} + /> + + )} +
{ + wrapperRef.current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }} + {...pickerProps.groupProps} + role="button" + tabIndex={0} + onClick={() => !pickerState.isOpen && pickerState.open()} + > + {"dateRange" in pickerState ? ( + <> + + + + ) : ( + + )} +
+
+ {children} +
+ {pickerState.isOpen && ( + + {calendar} + + )} +
+ + + ); +}; + +export default DatePickerAux; diff --git a/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.spec.tsx b/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.spec.tsx new file mode 100644 index 00000000..da2ae852 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.spec.tsx @@ -0,0 +1,1328 @@ +import { render, screen, within } from "@testing-library/react"; +import React, { FormEvent, useState } from "react"; +import { expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { CunninghamProvider } from ":/components/Provider"; +import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker"; +import { Button } from ":/components/Button"; + +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@internationalized/date", + ); + return { + ...mod, + // Note: Restoring mocks will cause the function to return 'undefined'. + // Consider providing a default implementation to be restored instead. + getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"), + }; +}); + +describe("", () => { + const expectDatesToBeEqual = ( + firstDate: Date | string | undefined | null, + secondDate: Date | string | undefined | null, + ) => { + expect(firstDate).toBeDefined(); + expect(secondDate).toBeDefined(); + expect(new Date(firstDate!).toLocaleDateString()).eq( + new Date(secondDate!).toLocaleDateString(), + ); + }; + + const expectCalendarToBeClosed = () => { + expect(screen.queryByRole("application")).toBeNull(); + }; + + const expectDateFieldsToBeDisplayed = () => { + expect(screen.queryAllByRole("presentation")).toBeTruthy(); + }; + + const expectDateFieldsToBeHidden = async () => { + const dateFields = await screen.queryAllByRole("presentation"); + expect(dateFields.length).eq(2); + dateFields.forEach((dateField) => { + expect(Array.from(dateField.parentElement!.classList)).contains( + "c__date-picker__inner--collapsed", + ); + }); + }; + + const expectDateRangePickerStateToBe = async (state: string) => { + const input = (await screen.findAllByRole("button"))![0]; + const classNames = + input.parentElement && Array.from(input.parentElement.classList); + expect(classNames).contains(`c__date-picker--${state}`); + }; + + const expectCalendarToBeOpen = () => { + const calendar = screen.queryByRole("application"); + expect(calendar).toBeDefined(); + expect(calendar).not.toBeNull(); + expect(Array.from(calendar!.classList)).contains( + "c__calendar__wrapper--opened", + ); + }; + + const expectedSelectedCells = (selectionSize: number) => { + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + }); + expect(selectedCells.length).eq(selectionSize); + }; + + it("toggles calendar", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const [input, button] = await screen.findAllByRole("button"); + + // It returns the clickable div. + expect(input.tagName).toEqual("DIV"); + + // It returns the toggle button. + expect(button.tagName).toEqual("BUTTON"); + + // Calendar is initially closed. + expectCalendarToBeClosed(); + await expectDateFieldsToBeHidden(); + + // Toggle button opens the calendar. + await user.click(button); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // Clicking again closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + await expectDateFieldsToBeHidden(); + + // Click on the input opens the calendar. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // Clicking again on the input should not close the calendar. + // Click could happen while selecting the date field input. + await user.click(input); + expectCalendarToBeOpen(); + expectDateFieldsToBeDisplayed(); + + // While the calendar open, clicking on + // the toggle button closes the calendar. + await user.click(button); + expectCalendarToBeClosed(); + }); + + it("focuses in the right order with no picked date range", async () => { + const user = userEvent.setup(); + render( + + + , + ); + // Get elements that should receive focus when no date is picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + expectCalendarToBeClosed(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + expectCalendarToBeClosed(); + + await user.keyboard("{Enter}"); + expectCalendarToBeOpen(); + + expectedSelectedCells(0); + }); + + it.each([ + ["2022-05-25T00:00:00.000Z", "2022-05-26T00:00:00.000Z"], + ["2023-06-01T00:00:00.000Z", "2024-05-26T00:00:00.000Z"], + ])("has a default value", async (start: string, end: string) => { + render( + + + , + ); + // Get picked date. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(start, startInput.textContent); + expectDatesToBeEqual(end, endInput.textContent); + expectDateFieldsToBeDisplayed(); + }); + + it("focuses in the right order with a default value", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Get elements that should receive focus when a date is already picked. + const [input, toggleButton] = await screen.findAllByRole("button")!; + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Navigate through elements using Tab. + await user.keyboard("{Tab}"); + expect(input).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(toggleButton).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(dayStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearStartSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(monthEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(dayEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(yearEndSegment).toHaveFocus(); + + await user.keyboard("{Tab}"); + expect(clearButton).toHaveFocus(); + }); + + it("picks a date range", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [input, toggleButton] = await screen.findAllByRole("button"); + await user.click(input); + + // Select all grid-cells. + const gridCells = await screen.findAllByRole("gridcell"); + + // Select the first clickable grid-cell. + const startGridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled"), + )![0], + ).getByRole("button")!; + + // Pick a start date. + const gridCellStartDate = startGridCellButton + .getAttribute("aria-label") + ?.replace("Today, ", ""); + await user.click(startGridCellButton); + + expectCalendarToBeOpen(); + + // Select the second clickable grid-cell. + const endGridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled"), + )![1], + ).getByRole("button")!; + + // Pick an end date. + const gridCellEndDate = endGridCellButton + .getAttribute("aria-label") + ?.replace("Today, ", ""); + await user.click(endGridCellButton); + + // Calendar should close on range's selection. + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Reopen the calendar. + await user.click(toggleButton); + + // Get the selected cells in calendar grid. + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + })!; + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", "") + ?.replace("Today, ", ""); + + // Make sure start and end dates are in the selected range, + // that describes the range with its start and end date. + expect(selectedRange).contains(gridCellStartDate); + expect(selectedRange).contains(gridCellEndDate); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(gridCellStartDate, startInput.textContent); + expectDatesToBeEqual(gridCellEndDate, endInput.textContent); + }); + + it("picks a date range with start and end date equal", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [input, toggleButton] = await screen.findAllByRole("button"); + await user.click(input); + + // Select all grid-cells. + const gridCells = await screen.findAllByRole("gridcell"); + + // Select the first clickable grid-cell. + const gridCellButton = within( + gridCells.filter( + (gridCell) => !gridCell.getAttribute("aria-disabled"), + )![0], + ).getByRole("button")!; + + // Pick a start date. + const gridCellDate = gridCellButton + .getAttribute("aria-label") + ?.replace("Today, ", ""); + await user.click(gridCellButton); + + expectCalendarToBeOpen(); + + // Click again on the same date, to select the end date. + await user.click(gridCellButton); + + // Calendar should close on range's selection. + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Reopen the calendar. + await user.click(toggleButton); + + // Get the selected cells in calendar grid. + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + })!; + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", "") + ?.replace("Today, ", ""); + + // Make sure start and end dates are in the selected range, + // that describes the range with its start and end date. + expect(selectedRange).contains(gridCellDate); + expect(selectedRange).contains(gridCellDate); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(gridCellDate, startInput.textContent); + expectDatesToBeEqual(gridCellDate, endInput.textContent); + }); + + it("picks a date range using keyboard", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + // Start range selection using keyboard. + await user.keyboard("{Enter}"); + expectCalendarToBeOpen(); + expectedSelectedCells(1); + + // Select the next cell. + await user.keyboard("{ArrowRight}"); + expectCalendarToBeOpen(); + expectedSelectedCells(2); + + // Select the next cell. + await user.keyboard("{ArrowRight}"); + expectCalendarToBeOpen(); + expectedSelectedCells(3); + + // End range selection using keyboard. + await user.keyboard("{Enter}"); + expectCalendarToBeClosed(); + + await user.click(input); + expectCalendarToBeOpen(); + + expectedSelectedCells(3); + + const selectedCells = screen.getAllByRole("gridcell", { + selected: true, + }); + + // Extract the selected range + const selectedRange = within(selectedCells[0]) + .getByRole("button")! + .getAttribute("aria-label") + ?.replace("selected", ""); + + // Make sure start and end dates are in the selected range. + expect(selectedRange).contains("January 1"); + expect(selectedRange).not.contains("January 2"); + expect(selectedRange).contains("January 3"); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual("2023-01-01", startInput.textContent); + expectDatesToBeEqual("2023-01-03", endInput.textContent); + }); + + it("picks a date range spanning multiple months", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + const startDate = "Tuesday, January 3, 2023"; + const endDate = "Tuesday, February 28, 2023"; + + // Select the start date. + const startGridCellButton = await screen.getByRole("button", { + name: startDate, + })!; + await user.click(startGridCellButton); + expectCalendarToBeOpen(); + + // Navigate to the next month. + const nextMonthButton = screen.getByRole("button", { + name: "Next month", + }); + await user.click(nextMonthButton); + expectCalendarToBeOpen(); + + // Select the end date. + const endGridCellButton = await screen.getByRole("button", { + name: endDate, + })!; + await user.click(endGridCellButton); + expectCalendarToBeClosed(); + + // Reopen calendar. + await user.click(input); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(startDate, startInput.textContent); + expectDatesToBeEqual(endDate, endInput.textContent); + }); + + it("picks a date range spanning multiple years", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + expectCalendarToBeOpen(); + + const startDate = "Tuesday, January 3, 2023"; + const endDate = "Thursday, January 4, 2024"; + + // Select the start date. + const startGridCellButton = await screen.getByRole("button", { + name: startDate, + })!; + await user.click(startGridCellButton); + expectCalendarToBeOpen(); + + // Navigate to the next year via dropdown. + const yearButton = screen.getByRole("combobox", { + name: "Select a year", + }); + await user.click(yearButton); + await user.click(screen.getByRole("option", { name: "2024" })); + expectCalendarToBeOpen(); + + // Select the end date (use findByRole to wait for grid re-render after dropdown). + const endGridCellButton = await screen.findByRole("button", { + name: endDate, + }); + await user.click(endGridCellButton); + expectCalendarToBeClosed(); + + // Reopen calendar. + await user.click(input); + + // Get the selected date in the date field. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(startDate, startInput.textContent); + expectDatesToBeEqual(endDate, endInput.textContent); + }); + + it("picks a date range spanning multiple years using dropdown", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const [input] = await screen.findAllByRole("button"); + await user.click(input); + expectCalendarToBeOpen(); + + // Select the start date. + const yearButton = screen.getByRole("combobox", { + name: "Select a year", + }); + await user.click(yearButton); + await user.click(screen.getByRole("option", { name: "1910" })); + + const monthButton = screen.getByRole("combobox", { + name: "Select a month", + }); + await user.click(monthButton); + await user.click(screen.getByRole("option", { name: "January" })); + + const startDateButton = await screen.findByRole("button", { + name: "Saturday, January 1, 1910", + }); + await user.click(startDateButton); + + // Select the end date. + await user.click(yearButton); + await user.click(screen.getByRole("option", { name: "2040" })); + await user.click(monthButton); + await user.click(screen.getByRole("option", { name: "September" })); + const endDateButton = await screen.findByRole("button", { + name: "Sunday, September 2, 2040", + }); + await user.click(endDateButton); + + // Make sure the correct dates are set. + expectCalendarToBeClosed(); + const startDate = "Saturday, January 1, 1910"; + const endDate = "Sunday, September 2, 2040"; + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual(startDate, startInput.textContent); + expectDatesToBeEqual(endDate, endInput.textContent); + }); + + it("types a date range", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Open calendar to display text segment + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Select the first segment, month one. + await user.click(monthStartSegment); + expect(monthStartSegment).toHaveFocus(); + + // Type month's start value. + await user.keyboard("{1}{2}"); + expect(dayStartSegment).toHaveFocus(); + + // Type day's start value. + await user.keyboard("{5}"); + expect(yearStartSegment).toHaveFocus(); + + // Type year's start value. + await user.keyboard("{2}{0}{2}{3}"); + expect(monthEndSegment).toHaveFocus(); + + // Type month's end value. + await user.keyboard("{1}{2}"); + expect(dayEndSegment).toHaveFocus(); + + // Type day's end value. + await user.keyboard("{6}"); + expect(yearEndSegment).toHaveFocus(); + + // Type year's end value. + await user.keyboard("{2}{0}{2}{3}"); + + // Check date field value. + expectDateFieldsToBeDisplayed(); + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("12/5/2023"); + expect(endInput.textContent).eq("12/6/2023"); + }); + + it("types an invalid date range", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + // Open calendar to display text segment + const input = (await screen.findAllByRole("button"))![0]; + await user.click(input); + + const [ + monthStartSegment, + dayStartSegment, + yearStartSegment, + monthEndSegment, + dayEndSegment, + yearEndSegment, + ] = await screen.findAllByRole("spinbutton")!; + + // Select the first segment, month one. + await user.click(monthStartSegment); + expect(monthStartSegment).toHaveFocus(); + + // Type month's start value. + await user.keyboard("{1}{2}"); + expect(dayStartSegment).toHaveFocus(); + + // Type day's start value. + await user.keyboard("{6}"); + expect(yearStartSegment).toHaveFocus(); + + // Type year's start value. + await user.keyboard("{2}{0}{2}{3}"); + expect(monthEndSegment).toHaveFocus(); + + // Type month's end value. + await user.keyboard("{1}{2}"); + expect(dayEndSegment).toHaveFocus(); + + // Type day's end value. + await user.keyboard("{5}"); + expect(yearEndSegment).toHaveFocus(); + + // Type year's end value. + await user.keyboard("{2}{0}{2}{3}"); + + // Check date field value. + expectDateFieldsToBeDisplayed(); + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("12/6/2023"); + expect(endInput.textContent).eq("12/5/2023"); + + // Make sure the date picker is showing error. + await expectDateRangePickerStateToBe("invalid"); + }); + + it("has an uncontrolled and a controlled value", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + , + ), + ).toThrow( + "You cannot use both defaultValue and value props on DateRangePicker component", + ); + }); + + it.each([ + ["this_not_a_valid_date", "2022-05-12T00:00:00.000Z"], + ["2022-05-12T00:00:00.000Z", "2023-13-13"], + ["2025-25-05T00:00:00.000Z", "2022-05-12T00:00:00.000Z"], + ])("has not a valid range value", async (start: string, end: string) => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(() => + render( + + + , + ), + ).toThrow(/Failed to parse date value:/); + }); + + it("has not a valid range value", async () => { + // Start date is superior to end date in the default range. + render( + + + , + ); + await expectDateRangePickerStateToBe("invalid"); + }); + + it("clears date", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectCalendarToBeOpen(); + + // Date field's value should be set to a placeholder value. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expect(startInput.textContent).eq("MM/DD/YYYY"); + expect(endInput.textContent).eq("MM/DD/YYYY"); + + const startGridCell = screen.getByRole("gridcell", { + name: "25", + })!; + + // Make sure start grid-cell is not selected anymore. + expect(startGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + startGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--start", + ), + ).toBe(false); + + // Make sure end grid-cell is not selected anymore. + const endGridCell = screen.getByRole("gridcell", { + name: "27", + })!; + expect(endGridCell.getAttribute("aria-selected")).toBeNull(); + expect( + endGridCell.classList.contains( + "c__calendar__wrapper__grid__week-row__background--range--end", + ), + ).toBe(false); + + // Close the calendar. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Make sure the empty date field is hidden when closing the calendar. + await expectDateFieldsToBeHidden(); + }); + + it.each([ + ["2023-01-01T00:00:00.000Z", "2023-01-01T00:00:00.000Z"], + ["2023-01-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], + ])( + "has a start or a end date inferior to minValue", + async (start: string, end: string) => { + render( + + + , + ); + await expectDateRangePickerStateToBe("invalid"); + }, + ); + + it.each([ + ["2023-01-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], + ["2023-03-01T00:00:00.000Z", "2023-03-01T00:00:00.000Z"], + ])( + "has a start or a end date superior to maxValue", + async (start: string, end: string) => { + render( + + + , + ); + await expectDateRangePickerStateToBe("invalid"); + }, + ); + + it("renders disabled", async () => { + render( + + + , + ); + await expectDateRangePickerStateToBe("disabled"); + + const [input, button] = await screen.findAllByRole("button"); + + // Make sure toggle button and click on input are disabled. + expect(input.getAttribute("aria-disabled")).eq("true"); + expect(button).toBeDisabled(); + + // Make sure the clear button is not visible and disabled. + expect( + screen.queryByRole("button", { name: "Clear date", hidden: true }), + ).toBeDisabled(); + + // Make sure each segment of the date field is disabled. + const dateFieldInputs = await screen.queryAllByRole("spinbutton"); + dateFieldInputs.forEach((dateFieldInput) => + expect(dateFieldInput).toHaveAttribute("aria-disabled"), + ); + }); + + it("renders focused", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + await expectDateRangePickerStateToBe("focused"); + }); + + it("renders a selected range", async () => { + const user = userEvent.setup(); + render( + + + , + ); + // Toggle button opens the calendar. + const button = (await screen.findAllByRole("button"))![0]; + await user.click(button); + + const selectedCells = screen.queryAllByRole("gridcell", { + selected: true, + }); + + // Make sure all selected cells render the selected styling. + selectedCells.forEach((selectedCell) => { + const cellButton = within(selectedCell).getByRole("button")!; + expect(Array.from(cellButton!.classList)).contains( + "c__calendar__wrapper__grid__week-row__button--selected", + ); + }); + const commonClassName = + "c__calendar__wrapper__grid__week-row__background--range"; + // Make sure the start of selection has the start styling. + const startCellButton = within(selectedCells[0]).getByRole("button")!; + expect(Array.from(startCellButton.parentElement!.classList)).contains( + `${commonClassName}--start`, + ); + // Make sure the end of selection has the end styling. + const endCellButton = within( + selectedCells[selectedCells.length - 1], + ).getByRole("button")!; + expect(Array.from(endCellButton.parentElement!.classList)).contains( + `${commonClassName}--end`, + ); + }); + + it("works controlled", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState<[string, string] | null>([ + "2023-04-25T00:00:00.000Z", + "2023-04-26T00:00:00.000Z", + ]); + return ( + +
+
Value = {value?.join(" ")}|
+ + setValue(e)} + /> +
+
+ ); + }; + render(); + + // Make sure value is selected. + screen.getByText( + "Value = 2023-04-25T00:00:00.000Z 2023-04-26T00:00:00.000Z|", + ); + + // Make sure value is initially render in the date field component. + const [startInput, endInput] = await screen.queryAllByRole("presentation"); + expectDatesToBeEqual("2023-04-25", startInput.textContent); + expectDatesToBeEqual("2023-04-26", endInput.textContent); + + // Open the calendar grid. + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + expectCalendarToBeOpen(); + + const startGridCell = within( + await screen.getByRole("gridcell", { name: "12" }), + ).getByRole("button")!; + + const endGridCell = within( + await screen.getByRole("gridcell", { name: "14" }), + ).getByRole("button")!; + + // Select a new value in the calendar grid. + await user.click(startGridCell); + await user.click(endGridCell); + expectCalendarToBeClosed(); + + // Make sure value is selected. + screen.getByText( + `Value = 2023-04-12T00:00:00.000Z 2023-04-14T00:00:00.000Z|`, + ); + + // Clear value. + const clearButton = screen.getByRole("button", { + name: "Clear", + }); + await user.click(clearButton); + + // Make sure value is cleared. + await expectDateFieldsToBeHidden(); + screen.getByText("Value = |"); + }); + + it("has a timezone", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const [value, setValue] = useState<[string, string] | null>(null); + return ( + +
+
Value = {value?.join(" ")}|
+ + setValue(e)} + timezone="America/Sao_Paulo" + /> +
+
+ ); + }; + render(); + + // Make sure any value is selected. + screen.getByText("Value = |"); + + const allSegments = await screen.getAllByRole("spinbutton"); + const startMonthSegment = allSegments![0]; + + // Select the first segment, month one. + await user.click(startMonthSegment); + expect(startMonthSegment).toHaveFocus(); + + // Type start date's value. + await user.keyboard("{5}{1}{0}{2}{0}{2}{3}"); + + // Type end date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Make sure values is selected at midnight on America/Sao_Paulo. + screen.getByText( + `Value = 2023-05-10T03:00:00.000Z 2023-05-12T03:00:00.000Z|`, + ); + }); + + it("submits forms data", async () => { + let formData: any; + const Wrapper = () => { + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + formData = { + datepickerStart: data.get("datepicker_start"), + datepickerEnd: data.get("datepicker_end"), + }; + }; + + return ( + +
+
+ + + +
+
+ ); + }; + render(); + + const user = userEvent.setup(); + const submitButton = screen.getByRole("button", { + name: "Submit", + }); + + // Submit the form being empty. + await user.click(submitButton); + expect(formData).toEqual({ + datepickerStart: "", + datepickerEnd: "", + }); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + const allSegments = await screen.getAllByRole("spinbutton"); + const startMonthSegment = allSegments![0]; + + // Select the first segment, month one. + await user.click(startMonthSegment); + expect(startMonthSegment).toHaveFocus(); + + // Type start date's value. + await user.keyboard("{5}{1}{0}{2}{0}{2}{3}"); + + // Type end date's value. + await user.keyboard("{5}{1}{2}{2}{0}{2}{3}"); + + // Submit form being filled with a date. + await user.click(submitButton); + expectCalendarToBeClosed(); + expectDateFieldsToBeDisplayed(); + + // Make sure form's value matches. + expect(formData).toEqual({ + datepickerStart: "2023-05-09T22:00:00.000Z", + datepickerEnd: "2023-05-11T22:00:00.000Z", + }); + + // Clear picked date. + const clearButton = screen.getByRole("button", { + name: "Clear date", + }); + await user.click(clearButton); + expectDateFieldsToBeDisplayed(); + + // Submit the form being empty. + await user.click(submitButton); + + // Date field disappears when the user click outside the comppnent. + await expectDateFieldsToBeHidden(); + + // Make sure form's value is null. + expect(formData).toEqual({ + datepickerStart: "", + datepickerEnd: "", + }); + }); + it("renders with className", async () => { + render( + + + , + ); + expect( + document.querySelector(".c__field.my-custom-class"), + ).toBeInTheDocument(); + }); + + describe("classic variant", () => { + it("renders with classic variant", async () => { + render( + + + , + ); + // In classic mode, both labels are rendered in a row above the wrapper + expect( + document.querySelector(".c__date-picker--classic"), + ).toBeInTheDocument(); + expect( + document.querySelector(".c__date-picker__range__labels"), + ).toBeInTheDocument(); + // Should have two labels in the labels row + const labels = document.querySelectorAll(".c__date-picker__label"); + expect(labels.length).toBe(2); + }); + + it("labels are always static in classic variant", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const startLabel = screen.getByText("Start date"); + const endLabel = screen.getByText("End date"); + + // In classic variant, labels are outside the wrapper with c__date-picker__label class + expect(startLabel.classList.contains("c__date-picker__label")).toBe(true); + expect(endLabel.classList.contains("c__date-picker__label")).toBe(true); + + // Open calendar + const toggleButton = (await screen.findAllByRole("button"))![1]; + await user.click(toggleButton); + + // Labels should still have the same class + expect(startLabel.classList.contains("c__date-picker__label")).toBe(true); + expect(endLabel.classList.contains("c__date-picker__label")).toBe(true); + }); + + it("defaults to floating variant", () => { + render( + + + , + ); + expect( + document.querySelector(".c__date-picker--classic"), + ).not.toBeInTheDocument(); + }); + }); + + describe("hideLabel", () => { + it("hides labels visually but keeps them accessible in floating variant", () => { + render( + + + , + ); + // Labels should be visually hidden via LabelledBox + const startLabel = screen.getByText("Start date"); + const endLabel = screen.getByText("End date"); + expect(startLabel.closest("label")).toHaveClass("c__offscreen"); + expect(endLabel.closest("label")).toHaveClass("c__offscreen"); + }); + + it("hides labels visually but keeps them accessible in classic variant", () => { + render( + + + , + ); + // Labels should be visually hidden with c__offscreen class + const startLabel = screen.getByText("Start date"); + const endLabel = screen.getByText("End date"); + expect(startLabel).toHaveClass("c__offscreen"); + expect(endLabel).toHaveClass("c__offscreen"); + // The visible labels row should not be present + expect( + document.querySelector(".c__date-picker__range__labels"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.tsx b/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.tsx new file mode 100644 index 00000000..19e773aa --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/DateRangePicker.tsx @@ -0,0 +1,133 @@ +import React, { useMemo, useRef, useState } from "react"; +import { DateValue } from "@internationalized/date"; +import { + DateRangePickerStateOptions, + useDateRangePickerState, +} from "@react-stately/datepicker"; +import { useDateRangePicker } from "@react-aria/datepicker"; +import { DateRange } from "react-aria"; +import { CalendarRange } from ":/components/Calendar/CalendarAux"; +import DatePickerAux, { + DatePickerAuxSubProps, +} from ":/components/Forms/DatePicker/DatePickerAux"; +import DateFieldBox from ":/components/Forms/DatePicker/DateField"; +import { + convertDateValueToString, + getDefaultPickerOptions, + parseRangeDateValue, +} from ":/components/Forms/DatePicker/utils"; + +export type DateRangePickerProps = DatePickerAuxSubProps & { + startLabel: string; + endLabel: string; + value?: null | [string, string]; + defaultValue?: [string, string]; + onChange?: (value: [string, string] | null) => void; +}; + +export const DateRangePicker = ({ + startLabel, + endLabel, + ...props +}: DateRangePickerProps) => { + if (props.defaultValue && props.value) { + throw new Error( + "You cannot use both defaultValue and value props on DateRangePicker component", + ); + } + const ref = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const options: DateRangePickerStateOptions = { + ...getDefaultPickerOptions(props), + value: + props.value === null + ? null + : parseRangeDateValue(props.value, props.timezone), + defaultValue: parseRangeDateValue(props.defaultValue, props.timezone), + onChange: (value: DateRange) => { + props.onChange?.( + value?.start && value.end + ? [ + convertDateValueToString(value.start, props.timezone), + convertDateValueToString(value.end, props.timezone), + ] + : null, + ); + }, + }; + const pickerState = useDateRangePickerState(options); + const { startFieldProps, endFieldProps, calendarProps, ...pickerProps } = + useDateRangePicker(options, pickerState, ref); + + const isClassic = props.variant === "classic"; + const labelAsPlaceholder = useMemo( + () => + !isFocused && + !pickerState.isOpen && + !pickerState.value?.start && + !pickerState.value?.end, + [pickerState.value, pickerState.isOpen, isFocused], + ); + + const calendar = ; + + return ( + { + pickerState.setValue({ + start: null as unknown as DateValue, + end: null as unknown as DateValue, + }); + }, + calendar, + // Pass labels for classic range mode + rangeLabels: isClassic + ? { + startLabel, + endLabel, + disabled: props.disabled, + hideLabel: props.hideLabel, + } + : undefined, + }} + ref={ref} + > + +
+ + + ); +}; diff --git a/packages/ui-components/src/components/Forms/DatePicker/_index.scss b/packages/ui-components/src/components/Forms/DatePicker/_index.scss new file mode 100644 index 00000000..0f27008a --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/_index.scss @@ -0,0 +1,296 @@ +@use "src/utils" as *; +@use "../../../utils/responsive" as *; + +.c__date-picker { + position: relative; + + &__label { + display: block; + font-size: var(--c--components--forms-labelledbox--classic-label-font-size); + font-weight: var(--c--components--forms-labelledbox--classic-label-font-weight); + font-family: var(--c--globals--font--families--base); + font-style: normal; + line-height: var(--c--components--forms-labelledbox--classic-label-line-height); + color: var(--c--components--forms-labelledbox--classic-label-color); + margin-bottom: var(--c--components--forms-labelledbox--classic-label-margin-bottom); + overflow: hidden; + text-overflow: ellipsis; + + &--disabled { + color: var(--c--components--forms-labelledbox--label-color--small--disabled); + } + } + + &--classic { + .c__date-picker__wrapper { + align-items: center; + height: 2.75rem; + } + + .c__date-picker__inner__action { + margin-top: 0; + } + + .c__date-picker__range__separator { + align-self: center; + flex-shrink: 0; + } + } + + &__wrapper { + border-radius: var(--c--components--forms-datepicker--border-radius); + border-width: var(--c--components--forms-datepicker--border-width); + border-color: var(--c--components--forms-datepicker--border-color); + border-style: var(--c--components--forms-datepicker--border-style); + display: flex; + transition: + border var(--c--globals--transitions--duration) + var(--c--globals--transitions--ease-out), + border-radius var(--c--globals--transitions--duration) + var(--c--globals--transitions--ease-out), + box-shadow var(--c--globals--transitions--duration) + var(--c--globals--transitions--ease-out); + padding: 0 0.5rem; + gap: 1rem; + box-sizing: border-box; + height: var(--c--components--forms-datepicker--height); + background-color: var(--c--components--forms-datepicker--background-color); + position: relative; + overflow: hidden; + + &--clickable { + cursor: pointer; + } + + &__icon { + display: flex; + align-items: center; + } + + &__toggle { + .icon { + font-size: var(--c--components--button--medium-icon-font-size); + } + } + } + + &__inner { + flex-grow: 1; + display: flex; + justify-content: space-between; + user-select: none; + min-width: 0; + + &--collapsed { + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + } + + &--clickable { + cursor: pointer; + } + + &__value { + display: block; + font-size: var(--c--components--forms-datepicker--font-size); + color: var(--c--components--forms-datepicker--value-color); + overflow: hidden; + flex-grow: 1; + + &__segment { + &--empty { + color: var(--c--components--forms-field--color); + } + + &:focus-visible { + background-color: var(--c--contextuals--background--semantic--brand--tertiary); + outline: none; + } + } + } + + &__action { + flex-shrink: 0; + margin-top: 1.375rem; + + &--empty { + color: var(--c--components--forms-field--color); + } + + &--hidden { + visibility: hidden; + transition: none; + } + } + } + + /** Modifiers */ + + &--disabled { + pointer-events: none; + .c__date-picker__wrapper { + color: var(--c--contextuals--content--semantic--disabled--primary); + border-color: var( + --c--components--forms-datepicker--border-color--disabled + ); + cursor: default; + pointer-events: none; + + &__toggle { + color: var(--c--contextuals--content--semantic--disabled--tertiary); + } + + .c__date-picker__inner { + &__action { + color: var(--c--contextuals--content--semantic--disabled--primary); + } + + &__value { + color: var(--c--components--forms-datepicker--value-color--disabled); + } + } + + label { + cursor: default; + } + } + } + + &--invalid { + .c__date-picker__wrapper { + border-color: var(--c--contextuals--border--semantic--error--primary); + + &__toggle { + color: var(--c--contextuals--content--semantic--error--secondary); + } + } + } + + &--success { + .c__date-picker__wrapper { + border-color: var(--c--contextuals--border--semantic--success--primary); + + &__toggle { + color: var(--c--contextuals--content--semantic--success--secondary); + } + } + } + + + &:hover { + .c__date-picker__wrapper { + border-radius: var( + --c--components--forms-datepicker--border-radius--hover + ); + border-color: var( + --c--components--forms-datepicker--border-color--hover + ); + box-shadow: 0 0 0 var(--c--components--forms-datepicker--border-width--hover) + var(--c--components--forms-datepicker--border-color--hover); + } + } + + /** + * Even if usually !important is evil, in this case it is on purpose. Focus styling most have a greater priority + * than :hover. But here hover has more css specificity, so we need !important. + */ + @mixin focused-wrapper { + border-radius: var( + --c--components--forms-datepicker--border-radius--focus + ) !important; + border-color: var( + --c--components--forms-datepicker--border-color--focus + ) !important; + box-shadow: 0 0 0 var(--c--components--forms-datepicker--border-width--focus) + var(--c--components--forms-datepicker--border-color--focus) !important; + + .c__date-picker__wrapper__toggle { + color: var( + --c--components--forms-datepicker--border-color--focus + ) !important; + } + + label { + color: var(--c--components--forms-datepicker--label-color--focus); + } + } + + &--focused { + .c__date-picker__wrapper { + @include focused-wrapper; + } + } + + .c__date-picker__wrapper:focus-visible { + outline: none; + @include focused-wrapper; + } + + &__range { + $component-min-width: px-to-rem(350px); + + &__labels { + display: flex; + margin-bottom: var(--c--components--forms-labelledbox--classic-label-margin-bottom); + + .c__date-picker__label { + flex: 1; + margin-bottom: 0; + } + + &__spacer { + // Space for the separator + width: 1rem; + flex-shrink: 0; + } + } + + // MUST READ: + // We can only use @container property for full-width fields, as the container-type: inline-size property + // should not be based on the children's width. We cannot at the same time use container-type and a default + // width. + // So in order to make this work we use @media for the non-full-width fields and @container for the full-width. + + &__container { + &:not(.c__field--full-width) { + min-width: $component-min-width; + + @include media(sm) { + min-width: auto; + + .c__date-picker__wrapper__icon { + display: none; + } + } + } + + &.c__field--full-width { + .c__date-picker__wrapper { + // The container-type must absolutely not be parent of the calendar popover or otherwise the popover will + // not be able to float above other DOM elements as a container-type: inline-size defines a new stacking + // context. + container-type: inline-size; + } + + @container (max-width: #{$component-min-width}) { + .c__date-picker__wrapper__icon { + display: none; + } + } + } + } + + &__separator { + background-color: var(--c--contextuals--border--semantic--neutral--tertiary); + height: px-to-rem(24px); + width: px-to-rem(1px); + margin: auto; + } + } +} diff --git a/packages/ui-components/src/components/Forms/DatePicker/index.mdx b/packages/ui-components/src/components/Forms/DatePicker/index.mdx new file mode 100644 index 00000000..ed5c8484 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/index.mdx @@ -0,0 +1,170 @@ +import { Canvas, Meta, Story, Source } from '@storybook/addon-docs'; +import { DatePicker } from "./index"; +import { DateRangePicker } from "./index"; +import * as Stories from "./index.stories" +import { ArgTypes } from '@storybook/blocks'; + + + + +# DatePicker + + +Cunningham provides a versatile DatePicker component to select or input a date in your form. It uses the headless +UI components provided by [React-Spectrum](https://react-spectrum.adobe.com/react-aria/useDatePicker.html) from Adobe. + +> Time features will be available soon. + + +## Basic + + + `} +/> + + + + +## Default value +You can use the following props to change the default value of the DatePicker component by using the `state` props. + + + +## Range + + + `} +/> + +When using it within a form, you'll access its values using separate inputs for the start and end values. The start input's +name will be the `DateRangePicker`'s name suffixed by `_start` and the end input's name will be the `DateRangePicker`'s name suffixed by `_end`. +Ex: If a `DateRangePicker` is named `"subscription"`, you would access its values as follow: + + + + + +## States + +You can use the following props to change the state of the DatePicker component by using the `state` props. + + + + +## Disabled + +As a regular input, you can disable it by using the `disabled` props. + + + + +## Texts + +As the component uses [Field](?path=/story/components-forms-field-doc--page), you can use the `text` props to provide a description of the checkbox. + + + +## Controlled / Non Controlled + +Like a native input, you can use the DatePicker component in a controlled or non controlled way. You can see the example below +using the component in a controlled way. + + + + +## MinValue / MaxValue + +You can pass a date range that are valid using the `minValue` and `maxValue` props. + + + +## Invalid date + +When passing an invalid date, for example outside of the valid range, the DatePicker component would render invalid. + + + +## International calendars + +When passing a locale value to the DatePicker components, dates would be automatically displayed in the appropriate calendar system. +By default, the DatePicker component uses the CunninghamProvider locale. + + + + + + + + +## Timezone management + +By default, the component uses the user's local timezone. When a timezone is passed, dates are selected at midnight in that specific timezone. +If a value or default value is provided, the component preserves the time unless the user clears the input. +When the input is cleared, the dates are reset to midnight in the current timezone. +The component accepts both UTC date-time strings and date-time strings with offsets from UTC. +However, it consistently returns the output in UTC time, ensuring uniformity across different timezone. + + +## Props + +You can see the list of props below. + +### DatePicker + + + +### DateRangePicker + + + + +## Design tokens + +Here are the custom design tokens defined by the datepicker. + +| Token | Description | +|--------------- |----------------------------- | +| background-color | Background color of the datepicker | +| border-color | Border color of the datepicker | +| border-color--hover | Border color of the datepicker on mouse hover | +| border-color--focus | Border color of the datepicker when focus | +| border-radius | Border radius of the datepicker | +| border-width | Border width of the datepicker | +| border-radius--hover | Border radius of the datepicker on mouse hover | +| border-radius--focus | Border radius of the datepicker when focused | +| value-color | Value color | +| value-color--disabled | Value color when disabled | +| font-size | Value font size | +| height | Height of the combo box | +| item-background-color--hover | Background color of the item on mouse hover (months/years menus) | +| item-background-color--selected | Background color of the selected item (months/years menus) | +| item-color | Color of the item (months/years menus) | +| item-font-size | Font size of the item (months/years menus) | +| menu-background-color | Background color of the menu (months/years menus) | +| grid-cell--border-color--today | Border color of the today grid-cell | +| grid-cell--color--today | Value color of the today grid-cell | +| range-selection-background-color | Value color of the selected grid-cells | +| label-color--focus | Label color when focused | + +See also [Field](?path=/story/components-forms-field-doc--page) diff --git a/packages/ui-components/src/components/Forms/DatePicker/index.stories.tsx b/packages/ui-components/src/components/Forms/DatePicker/index.stories.tsx new file mode 100644 index 00000000..b8ee6894 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/index.stories.tsx @@ -0,0 +1,342 @@ +import { Meta, StoryFn } from "@storybook/react"; +import React, { useState } from "react"; +import * as Yup from "yup"; +import { FormProvider, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { CunninghamProvider } from ":/components/Provider"; +import { Button } from ":/components/Button"; +import { DateRangePicker } from ":/components/Forms/DatePicker/DateRangePicker"; +import { DatePicker } from ":/components/Forms/DatePicker/DatePicker"; +import { onSubmit } from ":/components/Forms/Examples/ReactHookForm/reactHookFormUtils"; +import { RhfDatePicker } from ":/components/Forms/DatePicker/stories-utils"; + +export default { + title: "Components/Forms/DatePicker", + component: DatePicker, + argTypes: { + disabled: { + control: "boolean", + }, + state: { + control: "select", + options: ["default", "success", "error"], + }, + variant: { + control: "select", + options: ["floating", "classic"], + }, + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = () => ( +
+ +
+); + +export const Disabled = { + render: Template, + args: { disabled: true }, +}; + +export const DefaultValue = { + render: Template, + args: { defaultValue: "2023-05-24T00:00:00.000+00:00" }, +}; + +export const DisabledValue = { + render: Template, + args: { disabled: true, defaultValue: "2023-05-24T00:00:00.000+00:00" }, +}; + +export const Error = { + render: Template, + args: { + defaultValue: "2023-05-24T00:00:00.000+00:00", + state: "error", + text: "Something went wrong", + }, +}; + +export const Success = { + render: Template, + args: { + defaultValue: "2023-05-24T00:00:00.000+00:00", + state: "success", + text: "Well done", + }, +}; + +export const MinMaxValue = { + render: Template, + args: { + defaultValue: "2023-05-24T00:00:00.000+00:00", + minValue: "2023-04-23T00:00:00.000+00:00", + maxValue: "2023-06-23T00:00:00.000+00:00", + }, +}; + +export const InvalidValue = { + render: Template, + args: { + defaultValue: "2023-02-24T00:00:00.000+00:00", + minValue: "2023-04-23T00:00:00.000+00:00", + maxValue: "2023-06-23T00:00:00.000+00:00", + }, +}; + +export const WithText = { + render: Template, + args: { + defaultValue: "2023-05-24T00:00:00.000+00:00", + text: "This is a text, you can display anything you want here like warnings, information or errors.", + }, +}; + +export const Fullwidth = { + render: Template, + args: { + defaultValue: "2023-05-24T00:00:00.000+00:00", + fullWidth: true, + }, +}; + +export const CustomLocale = () => ( +
+ +
+); + +export const CunninghamLocale = () => ( +
+ + + +
+); + +export const Controlled = () => { + const [value, setValue] = useState("2023-04-25T12:00:00.000Z"); + return ( +
+
+ Value: {value?.toString()} +
+ { + setValue(e); + }} + /> + +
+ ); +}; + +export const ReactHookForm = () => { + const methods = useForm({ + defaultValues: { + date: "", + }, + resolver: yupResolver( + Yup.object().shape({ + date: Yup.string().required(), + }), + ), + }); + + return ( + +
+ + + +
+ ); +}; + +export const RangeDefault = () => { + return ( +
+ +
+ ); +}; + +export const RangeDefaultValue = () => { + return ( + + ); +}; + +export const RangeControlled = () => { + const [value, setValue] = useState<[string, string] | null>([ + "2023-05-23T13:37:00.000+02:00", + "2023-06-23T13:37:00.000+02:00", + ]); + return ( + <> +
Value: {value?.join(" ")}
+
+ setValue(e)} + /> + +
+ + ); +}; + +export const RangeControlledFull = () => { + const [value, setValue] = useState<[string, string] | null>([ + "2023-05-23T13:37:00.000+02:00", + "2023-06-23T13:37:00.000+02:00", + ]); + return ( + <> +
Value: {value?.join(" ")}
+
+ setValue(e)} + fullWidth={true} + /> + +
+ + ); +}; + +export const ClassicVariant = () => ( +
+ +
+); + +export const ClassicVariantWithValue = () => ( +
+ +
+); + +export const RangeClassicVariant = () => ( +
+ +
+); + +export const RangeClassicVariantWithValue = () => ( +
+ +
+); + +export const HiddenLabel = () => ( +
+ +
+); + +export const HiddenLabelClassic = () => ( +
+ +
+); + +export const RangeHiddenLabel = () => ( +
+ +
+); + +export const RangeHiddenLabelClassic = () => ( +
+ +
+); diff --git a/packages/ui-components/src/components/Forms/DatePicker/index.tsx b/packages/ui-components/src/components/Forms/DatePicker/index.tsx new file mode 100644 index 00000000..a8d71a25 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/index.tsx @@ -0,0 +1,4 @@ +export * from "./DatePicker"; +export * from "./DateRangePicker"; + +export * from "./utils"; diff --git a/packages/ui-components/src/components/Forms/DatePicker/stories-utils.tsx b/packages/ui-components/src/components/Forms/DatePicker/stories-utils.tsx new file mode 100644 index 00000000..580aab3e --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/stories-utils.tsx @@ -0,0 +1,27 @@ +import { Controller, useFormContext } from "react-hook-form"; +import React from "react"; +import { + DatePicker, + DatePickerProps, +} from ":/components/Forms/DatePicker/DatePicker"; + +export const RhfDatePicker = (props: DatePickerProps & { name: string }) => { + const { control } = useFormContext(); + return ( + { + return ( + + ); + }} + /> + ); +}; diff --git a/packages/ui-components/src/components/Forms/DatePicker/tokens.ts b/packages/ui-components/src/components/Forms/DatePicker/tokens.ts new file mode 100644 index 00000000..7677c112 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/tokens.ts @@ -0,0 +1,23 @@ +import { DefaultTokens } from "@gouvfr-lasuite/ui-tokens"; + +export const tokens = (defaults: DefaultTokens) => ({ + "border-color": defaults.contextuals.border.semantic.neutral.tertiary, + "border-color--disabled": + defaults.contextuals.border.semantic.neutral.tertiary, + "border-color--focus": defaults.contextuals.border.semantic.brand.primary, + "border-color--hover": defaults.contextuals.border.semantic.neutral.tertiary, + "border-radius": "8px", + "border-radius--focus": "2px", + "border-radius--hover": "2px", + "border-style": "solid", + "border-width": "1px", + "border-width--focus": "1px", + "border-width--hover": "1px", + "value-color": defaults.contextuals.content.semantic.neutral.tertiary, + "value-color--disabled": + defaults.contextuals.content.semantic.disabled.primary, + "font-size": defaults.globals.font.sizes.sm, + height: "3.5rem", + "background-color": defaults.contextuals.background.surface.primary, + "label-color--focus": defaults.contextuals.content.semantic.brand.primary, +}); diff --git a/packages/ui-components/src/components/Forms/DatePicker/utils.spec.tsx b/packages/ui-components/src/components/Forms/DatePicker/utils.spec.tsx new file mode 100644 index 00000000..9cb2c619 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/utils.spec.tsx @@ -0,0 +1,178 @@ +import { DateValue, parseAbsolute, parseDate } from "@internationalized/date"; +import { vi } from "vitest"; +import { + convertDateValueToString, + isValidTimeZone, + parseDateValue, + parseRangeDateValue, +} from ":/components/Forms/DatePicker/utils"; + +const expectDateToBeEqual = ( + parsedDate: DateValue | undefined, + expectedYear: number, + expectedMonth: number, + expectedDay: number, +) => { + expect(parsedDate).not.toEqual(undefined); + expect(parsedDate?.year).toEqual(expectedYear); + expect(parsedDate?.month).toEqual(expectedMonth); + expect(parsedDate?.day).toEqual(expectedDay); +}; + +vi.mock("@internationalized/date", async () => { + const mod = await vi.importActual( + "@internationalized/date", + ); + return { + ...mod, + // Note: Restoring mocks will cause the function to return 'undefined'. + // Consider providing a default implementation to be restored instead. + getLocalTimeZone: vi.fn().mockReturnValue("Europe/Paris"), + }; +}); + +describe("parseDateValue", () => { + it("parse a 'YYYY-MM-DDThh:mm:ssZ' date", () => { + const parsedDate = parseDateValue("2023-05-11T00:00:00.000Z"); + expectDateToBeEqual(parsedDate, 2023, 5, 11); + expect(parsedDate?.hour).toEqual(0); + }); + + it("parse a 'YYYY-MM-DDThh:mm:ss±hh:mm' date", () => { + const parsedDate = parseDateValue("2023-05-11T00:00:00.000+00:00"); + expectDateToBeEqual(parsedDate, 2023, 5, 11); + expect(parsedDate?.hour).toEqual(0); + }); + + it("should parse time to the right timezone", async () => { + const parsedDate = parseDateValue( + "2023-05-11T00:00:00.000Z", + "America/Sao_Paulo", + ); + expectDateToBeEqual(parsedDate, 2023, 5, 10); + expect(parsedDate?.hour).toEqual(21); + }); + + it.each([undefined, ""])("parse an empty or null date", (date) => { + const parsedDate = parseDateValue(date); + expect(parsedDate).toEqual(undefined); + }); + + it.each([ + "35/04/2024", + "2023-05-11", + "11 janvier 20O2", + "22.04.2022", + "22-4-2022", + "2022-04-1T00:00:00-00:00", + "2022-04-01 T00:00:00-00:00", + "2022-04-01T00:00:00.000", + ])("parse a wrong date", (wrongFormattedDate) => { + expect(() => parseDateValue(wrongFormattedDate)).toThrow( + /Failed to parse date value:/, + ); + }); + + it("should raise an error when timezone is invalid", async () => { + expect(() => + parseDateValue("2023-05-11T00:00:00.000Z", "Invalid/Timezone"), + ).toThrow(/Failed to parse date value:/); + }); +}); + +describe("parseRangeDateValue", () => { + it("parse a date range", () => { + const range = parseRangeDateValue([ + "2023-03-22T00:00:00.000Z", + "2023-04-22T00:00:00.000Z", + ]); + expectDateToBeEqual(range?.start, 2023, 3, 22); + expectDateToBeEqual(range?.end, 2023, 4, 22); + }); + + it.each([ + ["", "2023-03-22"], + ["2023-03-22", ""], + ])( + "parse a partially null or empty date range", + (start: string, end: string) => { + expect(parseRangeDateValue([start, end])).toEqual(undefined); + }, + ); + + it("parse an undefined date range", () => { + expect(parseRangeDateValue(undefined)).toEqual(undefined); + }); + + it("parse an inverted date range", () => { + // Utils function accepts start date superior to the end date + // However, DateRangePicker will trigger an error with the parsed range. + const range = parseRangeDateValue([ + "2023-05-22T00:00:00.000Z", + "2023-04-22T00:00:00.000Z", + ]); + expectDateToBeEqual(range?.start, 2023, 5, 22); + expectDateToBeEqual(range?.end, 2023, 4, 22); + }); +}); + +describe("convertDateValueToString", () => { + it("should return an empty string for null date", () => { + const date: DateValue | null = null; + const result = convertDateValueToString(date); + expect(result).toBe(""); + }); + + it("should return a UTC ISO 8601 string from a CalendarDate", async () => { + const date = parseDate("2023-05-25"); + const result = convertDateValueToString(date); + expect(result).toEqual("2023-05-24T22:00:00.000Z"); + }); + + it("should return a UTC ISO 8601 string from a ZonedDateTime", async () => { + // Europe/Paris is the mocked local timezone. + const date = parseAbsolute("2023-05-25T00:00:00.000+02:00", "Europe/Paris"); + const result = convertDateValueToString(date); + expect(result).toEqual("2023-05-24T22:00:00.000Z"); + }); + + it("should convert time to the right timezone", async () => { + const date = parseDate("2023-05-25"); + const result = convertDateValueToString(date, "America/Sao_Paulo"); + expect(result).toEqual("2023-05-25T03:00:00.000Z"); + }); + + it("should raise an error when timezone is invalid", async () => { + const date = parseDate("2023-05-25"); + expect(() => convertDateValueToString(date, "Invalid/Timezone")).toThrow( + /Failed to convert date value to string:/, + ); + }); +}); + +describe("isValidTimeZone", () => { + it.each(["UTC", "Europe/Paris", "America/Sao_Paulo"])( + "should return true when timezone is valid", + (timezone) => { + const isValid = isValidTimeZone(timezone); + expect(isValid).toBe(true); + }, + ); + + it("should return false when timezone is invalid", () => { + const isNotValid = isValidTimeZone("Invalid/Timezone"); + expect(isNotValid).toBe(false); + }); + + it("should return false when Intl or time zones are not available", () => { + // Mock Intl to simulate the absence of Intl or time zones support + const originalDateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => { + throw new Error("Time zones are not available"); + }); + const result = isValidTimeZone("Europe/Paris"); + expect(result).toBe(false); + // Restore the original implementation after the test + (Intl as any).DateTimeFormat = originalDateTimeFormat; + }); +}); diff --git a/packages/ui-components/src/components/Forms/DatePicker/utils.ts b/packages/ui-components/src/components/Forms/DatePicker/utils.ts new file mode 100644 index 00000000..aa6a32e1 --- /dev/null +++ b/packages/ui-components/src/components/Forms/DatePicker/utils.ts @@ -0,0 +1,114 @@ +import { + DateValue, + parseAbsoluteToLocal, + ZonedDateTime, + toZoned, + getLocalTimeZone, + parseAbsolute, +} from "@internationalized/date"; +import { DateRange } from "react-aria"; +import { DatePickerAuxSubProps } from ":/components/Forms/DatePicker/DatePickerAux"; + +/** + * Checks if a given time zone is valid and supported by the environment. + * @param timezone The time zone to validate. + * @returns true if the time zone is valid and supported, false otherwise. + */ +export const isValidTimeZone = (timezone: string) => { + try { + // Check if Intl is available and supports time zones + if (!Intl || !Intl.DateTimeFormat().resolvedOptions().timeZone) { + throw new Error("Time zones are not available in this environment"); + } + + // Test if the provided time zone is valid + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + + return true; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_error) { + // If an error occurs, it could be due to an invalid time zone or lack of Intl support + return false; + } +}; + +/** + * Parses an ISO 8601 date and time string with a UTC offset or in UTC. + * @param rawDate - The raw date string to parse. + * @param timezone - (Optional) The timezone to use for parsing. If not provided, the local timezone will be used. + * @returns The parsed ZonedDateTime object or undefined if the input date is undefined. + * @throws {Error} - If an invalid date or timezone format is encountered. + */ +export const parseDateValue = ( + rawDate: string | undefined, + timezone?: string, +): undefined | ZonedDateTime => { + if (!rawDate) { + return undefined; + } + try { + if (timezone && !isValidTimeZone(timezone)) { + throw new Error("Invalid timezone provided."); + } + return timezone + ? parseAbsolute(rawDate, timezone) + : parseAbsoluteToLocal(rawDate); + } catch (e) { + const errorMessage = e instanceof Error ? ": " + e.message : "."; + throw new Error("Failed to parse date value" + errorMessage); + } +}; + +/** + * Parses a range of ISO 8601 date and time string with a UTC offset or in UTC. + * @param rawRange - The range of raw date strings to parse. + * @param timezone - (Optional) The timezone to use for parsing. If not provided, the local timezone will be used. + * @returns The parsed DateRange object or undefined if the input date range is undefined or uncomplete. + */ +export const parseRangeDateValue = ( + rawRange: [string, string] | undefined, + timezone?: string, +): DateRange | undefined => { + if (!rawRange || !rawRange[0] || !rawRange[1]) { + return undefined; + } + return { + start: parseDateValue(rawRange[0], timezone)!, + end: parseDateValue(rawRange[1], timezone)!, + }; +}; + +/** + * Converts a DateValue object to an ISO 8601 formatted string in UTC. + * @param date - The DateValue object to convert. + * @param timezone - (Optional) The timezone to use for the conversion. If not provided, the local timezone will be used. + * @returns The string representation of the date or an empty string if the input date is null. + * @throws {Error} - If an invalid date or timezone format is encountered. + */ +export const convertDateValueToString = ( + date: DateValue | null, + timezone?: string, +): string => { + try { + if (!date) { + return ""; + } + const localTimezone = timezone || getLocalTimeZone(); + if (!isValidTimeZone(localTimezone)) { + throw new Error("Invalid timezone provided."); + } + return toZoned(date, localTimezone).toAbsoluteString(); + } catch (e) { + const errorMessage = e instanceof Error ? ": " + e.message : "."; + throw new Error("Failed to convert date value to string" + errorMessage); + } +}; + +export const getDefaultPickerOptions = (props: DatePickerAuxSubProps): any => ({ + minValue: parseDateValue(props.minValue, props.timezone), + maxValue: parseDateValue(props.maxValue, props.timezone), + shouldCloseOnSelect: true, + granularity: "day", + isDisabled: props.disabled, + label: props.label, +}); diff --git a/packages/ui-components/src/components/Forms/Examples/EdgeCases/index.stories.tsx b/packages/ui-components/src/components/Forms/Examples/EdgeCases/index.stories.tsx new file mode 100644 index 00000000..bf6abbb4 --- /dev/null +++ b/packages/ui-components/src/components/Forms/Examples/EdgeCases/index.stories.tsx @@ -0,0 +1,92 @@ +import { Meta } from "@storybook/react"; +import React from "react"; +import { Input } from ":/components/Forms/Input"; +import { Checkbox } from ":/components/Forms/Checkbox"; +import { Button } from ":/components/Button"; +import { Select } from ":/components/Forms/Select"; +import { CunninghamProvider } from ":/components/Provider"; +import { FileUploader } from ":/components/Forms/FileUploader"; +import { Switch } from ":/components/Forms/Switch"; + +export default { + title: "Components/Forms/Examples/Edge Cases", +} as Meta; + +export const OverflowLabels = () => { + const overflow = " and some very long overflowing text"; + return ( + +
+

+ Application +

+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ Your curriculum vitae +
+ +
+
+ + +
+ + + + Need help ? + + + + ); +}; diff --git a/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Login.stories.tsx b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Login.stories.tsx new file mode 100644 index 00000000..66c83430 --- /dev/null +++ b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Login.stories.tsx @@ -0,0 +1,99 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { Meta } from "@storybook/react"; +import React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import * as Yup from "yup"; +import { RhfInput } from ":/components/Forms/Input/stories-utils"; +import { Checkbox } from ":/components/Forms/Checkbox"; +import { Button } from ":/components/Button"; +import { + getFieldState, + getFieldErrorMessage, + onSubmit, +} from "./reactHookFormUtils"; + +export default { + title: "Components/Forms/Examples/React-Hook-Form", +} as Meta; + +interface LoginStoryFormValues { + email: string; + password: string; + rememberMe: boolean; +} + +const loginSchema = Yup.object().shape({ + email: Yup.string().email().required(), + password: Yup.string().min(8).required(), + rememberMe: Yup.bool().required().oneOf([true]), +}); + +export const Login = () => { + const methods = useForm({ + defaultValues: { + email: "", + password: "", + rememberMe: false, + }, + mode: "onChange", + reValidateMode: "onChange", + resolver: yupResolver(loginSchema), + }); + + return ( + +
+

+ Log in +

+ + +
+ +
+ +
+ You do not have an account?{" "} + + Register + +
+ +
+ ); +}; diff --git a/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Sports.stories.tsx b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Sports.stories.tsx new file mode 100644 index 00000000..d186dd7c --- /dev/null +++ b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/Sports.stories.tsx @@ -0,0 +1,190 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { Meta } from "@storybook/react"; +import React, { useEffect } from "react"; +import { useForm, FormProvider } from "react-hook-form"; +import * as Yup from "yup"; +import { RhfInput } from ":/components/Forms/Input/stories-utils"; +import { Button } from ":/components/Button"; +import { Radio, RadioGroup } from ":/components/Forms/Radio"; +import { RhfSelect } from ":/components/Forms/Select/stories-utils"; +import { + getFieldState, + getFieldErrorMessage, + onSubmit, +} from "./reactHookFormUtils"; + +export default { + title: "Components/Forms/Examples/React-Hook-Form", +} as Meta; + +interface SportsStoryFormValues { + firstName: string; + lastName: string; + gender: string; + competition: string; + rewards: string[]; +} + +const sportsSchema = Yup.object().shape({ + firstName: Yup.string().required(), + lastName: Yup.string().required(), + gender: Yup.string().required(), + competition: Yup.string().defined().required(), + rewards: Yup.array().of(Yup.string().defined()).defined(), +}); + +export interface SportProps { + values?: SportsStoryFormValues; +} + +const SportsBase = ({ values }: SportProps) => { + const methods = useForm({ + defaultValues: { + firstName: "", + lastName: "", + rewards: [], + }, + mode: "onChange", + reValidateMode: "onChange", + resolver: yupResolver(sportsSchema), + }); + + useEffect(() => { + methods.reset(values); + }, [values]); + + useEffect(() => { + methods.setFocus("competition"); + }, [methods.setFocus]); + + return ( + +
+

+ Register +

+ +
+
+ Gender +
+ + + + + +
+
+ + +
+ + + + + + Need help ? + + +
+ ); +}; + +export const Sports = () => ; + +export const SportsEdit = () => ( + +); diff --git a/packages/ui-components/src/components/Forms/Examples/ReactHookForm/reactHookFormUtils.tsx b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/reactHookFormUtils.tsx new file mode 100644 index 00000000..9ac4bf35 --- /dev/null +++ b/packages/ui-components/src/components/Forms/Examples/ReactHookForm/reactHookFormUtils.tsx @@ -0,0 +1,26 @@ +import { FieldValues, FormState } from "react-hook-form"; + +export function getFieldState( + field: keyof FormValues, + formState: FormState, +) { + if (field in formState.errors) { + return "error"; + } + return "default"; +} + +export function getFieldErrorMessage( + field: keyof FormValues, + formState: FormState, +): string { + const errorMessage = formState.errors[field]?.message; + if (!errorMessage) { + return ""; + } + return errorMessage as string; +} + +export function onSubmit(data: FormValues) { + alert(`Submited form values: ${JSON.stringify(data)}`); +} diff --git a/packages/ui-components/src/components/Forms/Examples/index.stories.tsx b/packages/ui-components/src/components/Forms/Examples/index.stories.tsx new file mode 100644 index 00000000..d67608ff --- /dev/null +++ b/packages/ui-components/src/components/Forms/Examples/index.stories.tsx @@ -0,0 +1,366 @@ +import { Meta } from "@storybook/react"; +import React, { useState } from "react"; +import { DatePicker, DateRangePicker } from ":/components/Forms/DatePicker"; +import { Input } from ":/components/Forms/Input"; +import { Checkbox } from ":/components/Forms/Checkbox"; +import { Button } from ":/components/Button"; +import { Select } from ":/components/Forms/Select"; +import { FileUploader } from ":/components/Forms/FileUploader"; +import { Switch } from ":/components/Forms/Switch"; +import { Radio } from ":/components/Forms/Radio"; +import { TextArea } from ":/components/Forms/TextArea"; + +export default { + title: "Components/Forms/Examples", +} as Meta; + +export const Login = () => { + return ( +
+

+ Log in +

+ + +
+ +
+ +
+ You do not have an account?{" "} + + Register + +
+
+ ); +}; + +export const Application = () => { + return ( +
+

+ Application +

+ + + + +
+
+ +
+ +
+
+
+ Your curriculum vitae +
+ +
+