diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-alpha.yml index b968dd17558..e4c5830d8a6 100644 --- a/.github/workflows/publish-alpha.yml +++ b/.github/workflows/publish-alpha.yml @@ -27,6 +27,8 @@ jobs: runs-on: ubuntu-22.04 timeout-minutes: 30 steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/prerelease-publish with: node-auth-token: ${{ secrets.NODE_AUTH_TOKEN }} diff --git a/.nx/version-plans/version-plan-1778852866762.md b/.nx/version-plans/version-plan-1778852866762.md new file mode 100644 index 00000000000..dd4727a7c45 --- /dev/null +++ b/.nx/version-plans/version-plan-1778852866762.md @@ -0,0 +1,11 @@ +--- +gamut-illustrations: patch +gamut-patterns: patch +gamut-styles: major +gamut-icons: patch +gamut-tests: patch +gamut-kit: major +gamut: major +--- + +Remove deprecated colors from gamut-styles diff --git a/packages/gamut-icons/CHANGELOG.md b/packages/gamut-icons/CHANGELOG.md index a365185ef58..0c4dfcf5d3d 100644 --- a/packages/gamut-icons/CHANGELOG.md +++ b/packages/gamut-icons/CHANGELOG.md @@ -1,3 +1,16 @@ +## 9.57.5 (2026-05-14) + +### 🧱 Updated Dependencies + +- Updated gamut-styles to 18.0.0 +- Updated gamut-tests to 6.0.1 + +## 9.57.4 (2026-05-04) + +### 🧱 Updated Dependencies + +- Updated gamut-tests to 6.0.0 + ## 9.57.3 (2026-04-29) ### 🧱 Updated Dependencies diff --git a/packages/gamut-icons/package.json b/packages/gamut-icons/package.json index 2963938184f..2d784ae2e44 100644 --- a/packages/gamut-icons/package.json +++ b/packages/gamut-icons/package.json @@ -1,10 +1,10 @@ { "name": "@codecademy/gamut-icons", "description": "Icon library for codecademy.com", - "version": "9.57.3", + "version": "9.57.5", "author": "Codecademy ", "dependencies": { - "@codecademy/gamut-styles": "17.14.0", + "@codecademy/gamut-styles": "18.0.0", "@codecademy/variance": "0.26.1" }, "files": [ diff --git a/packages/gamut-illustrations/CHANGELOG.md b/packages/gamut-illustrations/CHANGELOG.md index 90ac2c40615..c2a0c51c78f 100644 --- a/packages/gamut-illustrations/CHANGELOG.md +++ b/packages/gamut-illustrations/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.58.11 (2026-05-14) + +### 🧱 Updated Dependencies + +- Updated gamut-styles to 18.0.0 + ## 0.58.10 (2026-04-29) ### 🧱 Updated Dependencies diff --git a/packages/gamut-illustrations/package.json b/packages/gamut-illustrations/package.json index 81f02a6961e..e0d8ac7ccbd 100644 --- a/packages/gamut-illustrations/package.json +++ b/packages/gamut-illustrations/package.json @@ -1,11 +1,11 @@ { "name": "@codecademy/gamut-illustrations", "description": "Illustrations library for Codecademy", - "version": "0.58.10", + "version": "0.58.11", "author": "Codecademy Engineering ", "bugs": "https://github.com/Codecademy/gamut/issues", "dependencies": { - "@codecademy/gamut-styles": "17.14.0", + "@codecademy/gamut-styles": "18.0.0", "classnames": "^2.2.5" }, "files": [ diff --git a/packages/gamut-kit/CHANGELOG.md b/packages/gamut-kit/CHANGELOG.md index fc4c5970e80..7b3e65b6df1 100644 --- a/packages/gamut-kit/CHANGELOG.md +++ b/packages/gamut-kit/CHANGELOG.md @@ -1,3 +1,46 @@ +## 0.6.599 (2026-05-20) + +### 🧱 Updated Dependencies + +- Updated gamut to 68.6.1 + +## 0.6.598 (2026-05-14) + +### 🧱 Updated Dependencies + +- Updated gamut-illustrations to 0.58.11 +- Updated gamut-patterns to 0.10.30 +- Updated gamut-styles to 18.0.0 +- Updated gamut-icons to 9.57.5 +- Updated gamut-tests to 6.0.1 +- Updated gamut to 68.6.0 + +## 0.6.597 (2026-05-08) + +### 🧱 Updated Dependencies + +- Updated gamut to 68.5.1 + +## 0.6.596 (2026-05-04) + +### 🧱 Updated Dependencies + +- Updated gamut-icons to 9.57.4 +- Updated gamut-tests to 6.0.0 +- Updated gamut to 68.5.0 + +## 0.6.595 (2026-05-04) + +### 🧱 Updated Dependencies + +- Updated gamut to 68.4.1 + +## 0.6.594 (2026-05-04) + +### 🧱 Updated Dependencies + +- Updated gamut to 68.4.0 + ## 0.6.593 (2026-04-29) ### 🧱 Updated Dependencies diff --git a/packages/gamut-kit/package.json b/packages/gamut-kit/package.json index ba5b0225ac7..77e4df3ef63 100644 --- a/packages/gamut-kit/package.json +++ b/packages/gamut-kit/package.json @@ -1,15 +1,15 @@ { "name": "@codecademy/gamut-kit", "description": "Styleguide & Component library for Codecademy", - "version": "0.6.593", + "version": "0.6.599", "author": "Codecademy Engineering ", "dependencies": { - "@codecademy/gamut": "68.3.0", - "@codecademy/gamut-icons": "9.57.3", - "@codecademy/gamut-illustrations": "0.58.10", - "@codecademy/gamut-patterns": "0.10.29", - "@codecademy/gamut-styles": "17.14.0", - "@codecademy/gamut-tests": "5.3.4", + "@codecademy/gamut": "68.6.1", + "@codecademy/gamut-icons": "9.57.5", + "@codecademy/gamut-illustrations": "0.58.11", + "@codecademy/gamut-patterns": "0.10.30", + "@codecademy/gamut-styles": "18.0.0", + "@codecademy/gamut-tests": "6.0.1", "@codecademy/variance": "0.26.1", "component-test-setup": "^0.3.1" }, diff --git a/packages/gamut-patterns/CHANGELOG.md b/packages/gamut-patterns/CHANGELOG.md index 5e39a192598..6a37cea370a 100644 --- a/packages/gamut-patterns/CHANGELOG.md +++ b/packages/gamut-patterns/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.10.30 (2026-05-14) + +### 🧱 Updated Dependencies + +- Updated gamut-styles to 18.0.0 + ## 0.10.29 (2026-04-29) ### 🧱 Updated Dependencies diff --git a/packages/gamut-patterns/package.json b/packages/gamut-patterns/package.json index af7f31bec07..bb5fc47dcff 100644 --- a/packages/gamut-patterns/package.json +++ b/packages/gamut-patterns/package.json @@ -1,11 +1,11 @@ { "name": "@codecademy/gamut-patterns", "description": "Pattern library for Codecademy", - "version": "0.10.29", + "version": "0.10.30", "author": "Codecademy Engineering ", "bugs": "https://github.com/Codecademy/gamut/issues", "dependencies": { - "@codecademy/gamut-styles": "17.14.0", + "@codecademy/gamut-styles": "18.0.0", "@codecademy/variance": "0.26.1", "classnames": "^2.2.5" }, diff --git a/packages/gamut-styles/CHANGELOG.md b/packages/gamut-styles/CHANGELOG.md index f3d2d88101e..5913d55f8aa 100644 --- a/packages/gamut-styles/CHANGELOG.md +++ b/packages/gamut-styles/CHANGELOG.md @@ -1,3 +1,13 @@ +# 18.0.0 (2026-05-14) + +### ⚠️ Breaking Changes + +- Updates LXStudio theme to new guidelines ([#3341](https://github.com/Codecademy/gamut/pull/3341)) + +### ❤️ Thank You + +- cassie spain @dreamwasp + ## 17.14.0 (2026-04-29) ### 🚀 Features diff --git a/packages/gamut-styles/README.md b/packages/gamut-styles/README.md index a90586b56d5..985aed1a896 100644 --- a/packages/gamut-styles/README.md +++ b/packages/gamut-styles/README.md @@ -1,23 +1,19 @@ # Gamut Styles -Base SCSS for Codecademy +Design tokens, Emotion themes, and system props for the Gamut design system. -## Variables/ +`@codecademy/gamut-styles` is the styling layer behind Gamut components. Wrap your app in `GamutProvider` to supply a theme; import tokens, system props, and helpers from the package entry (`src/index.ts`). -This folder houses all shared SCSS style variables. -It also contains a JavaScript file with color variables. +**`variables/`** — Raw tokens (colors, spacing, typography, breakpoints, etc.) used to build themes. -## Core/ +**`themes/`** — Composed Emotion themes. `coreTheme` (exported as `theme`) is the default; `platform`, `admin`, `lxStudio`, and `percipio` extend it for other contexts. -This folder contains a base stylesheet for the app. -This should be imported **once** in your application. +**`variance/`** — System props and CSS-in-JS helpers for styled components. -## Utils/ +Also exports color-mode utilities (`ColorMode`, `Background`), global styles, Emotion cache setup, and assorted styling helpers under `globals/`, `cache/`, `styles/`, and `utilities/`. -This folder contains Sass functions (pure utilities with no stylesheet output values) and mixins (outputs CSS) -to be used as needed both in Gamut and across the Codecademy app. +```tsx +import { GamutProvider, theme } from '@codecademy/gamut-styles'; -The `utils.scss` and `core.scss` just import the index files from their respective folders, to make the syntax to import them from elsewhere easier, e.g.: - -`@use "~@codecademy/gamut-styles/utils";` -`@use "~@codecademy/gamut-styles/core";` +{/* app */}; +``` diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index b0058dcf988..b7173bedd8e 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -1,7 +1,7 @@ { "name": "@codecademy/gamut-styles", "description": "Styleguide & Component library for codecademy.com", - "version": "17.14.0", + "version": "18.0.0", "author": "Jake Hiller ", "dependencies": { "@codecademy/variance": "0.26.1", diff --git a/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx b/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx index 738d1b3d243..15fc801e839 100644 --- a/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx +++ b/packages/gamut-styles/src/__tests__/AssetProvider.test.tsx @@ -13,36 +13,45 @@ jest.mock('../utilities/fontUtils', () => ({ getFonts: require('./fontUtilsMock').getFontsMock, })); -jest.mock('../remoteAssets/fonts', () => ({ - webFonts: { - core: [ - { - filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', - extensions: ['woff2', 'woff'], - name: 'Apercu', - }, - { - filePath: 'https://www.codecademy.com/gamut/apercu-bold-pro', - extensions: ['woff2', 'woff'], - name: 'Apercu', - weight: 'bold', - }, - ], - percipio: [ - { - filePath: 'https://www.codecademy.com/gamut/roboto-regular', - extensions: ['woff2', 'woff'], - name: 'Roboto', - }, - { - filePath: 'https://www.codecademy.com/gamut/roboto-bold', - extensions: ['woff2', 'woff'], - name: 'Roboto', - weight: 'bold', - }, - ], - }, -})); +jest.mock('../remoteAssets/fonts', () => { + const percipio = [ + { + filePath: 'https://www.codecademy.com/gamut/SkillsoftText-Regular', + extensions: ['woff2', 'woff'], + name: 'Skillsoft Text', + }, + { + filePath: 'https://www.codecademy.com/gamut/roboto-regular', + extensions: ['woff2', 'woff'], + name: 'Roboto', + }, + { + filePath: 'https://www.codecademy.com/gamut/roboto-bold', + extensions: ['woff2', 'woff'], + name: 'Roboto', + weight: 'bold', + }, + ]; + return { + webFonts: { + core: [ + { + filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', + extensions: ['woff2', 'woff'], + name: 'Apercu', + }, + { + filePath: 'https://www.codecademy.com/gamut/apercu-bold-pro', + extensions: ['woff2', 'woff'], + name: 'Apercu', + weight: 'bold', + }, + ], + percipio, + lxStudio: percipio, + }, + }; +}); const mockGetFonts = getFontsMock; diff --git a/packages/gamut-styles/src/__tests__/fontLoading.test.tsx b/packages/gamut-styles/src/__tests__/fontLoading.test.tsx index 0d9ae7c3193..b96acc16a96 100644 --- a/packages/gamut-styles/src/__tests__/fontLoading.test.tsx +++ b/packages/gamut-styles/src/__tests__/fontLoading.test.tsx @@ -10,24 +10,33 @@ jest.mock('../utilities/fontUtils', () => ({ getFonts: require('./fontUtilsMock').getFontsMock, })); -jest.mock('../remoteAssets/fonts', () => ({ - webFonts: { - core: [ - { - filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', - extensions: ['woff2', 'woff'], - name: 'Apercu', - }, - ], - percipio: [ - { - filePath: 'https://www.codecademy.com/gamut/roboto-regular', - extensions: ['woff2', 'woff'], - name: 'Roboto', - }, - ], - }, -})); +jest.mock('../remoteAssets/fonts', () => { + const percipio = [ + { + filePath: 'https://www.codecademy.com/gamut/SkillsoftText-Regular', + extensions: ['woff2', 'woff'], + name: 'Skillsoft Text', + }, + { + filePath: 'https://www.codecademy.com/gamut/roboto-regular', + extensions: ['woff2', 'woff'], + name: 'Roboto', + }, + ]; + return { + webFonts: { + core: [ + { + filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', + extensions: ['woff2', 'woff'], + name: 'Apercu', + }, + ], + percipio, + lxStudio: percipio, + }, + }; +}); const mockGetFonts = getFontsMock; diff --git a/packages/gamut-styles/src/remoteAssets/fonts.ts b/packages/gamut-styles/src/remoteAssets/fonts.ts index 4e28b89471a..765c333b878 100644 --- a/packages/gamut-styles/src/remoteAssets/fonts.ts +++ b/packages/gamut-styles/src/remoteAssets/fonts.ts @@ -1,4 +1,4 @@ -import { FontConfig } from '../utilities/fontUtils'; +import type { FontConfig } from '../utilities/fontUtils'; export const FONT_ASSET_PATH = `https://www.codecademy.com/gamut`; @@ -43,7 +43,92 @@ export const core: readonly FontConfig[] = [ }, ]; -export const percipio: readonly FontConfig[] = [ +/** + * Skillsoft Sans (accent) + Skillsoft Text (base). + * Weights 400 / 500 / 700 + italics; 500 uses Medium files for title/bold tokens. + */ +const skillsoftFamilyFonts: readonly FontConfig[] = [ + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-Regular`, + extensions, + name: 'Skillsoft Text', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-RegularItalic`, + extensions, + name: 'Skillsoft Text', + style: 'italic', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-Medium`, + extensions, + name: 'Skillsoft Text', + weight: 500, + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-MediumItalic`, + extensions, + name: 'Skillsoft Text', + weight: 500, + style: 'italic', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-Bold`, + extensions, + name: 'Skillsoft Text', + weight: 700, + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftText-BoldItalic`, + extensions, + name: 'Skillsoft Text', + weight: 700, + style: 'italic', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-Regular`, + extensions, + name: 'Skillsoft Sans', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-RegularItalic`, + extensions, + name: 'Skillsoft Sans', + style: 'italic', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-Medium`, + extensions, + name: 'Skillsoft Sans', + weight: 500, + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-MediumItalic`, + extensions, + name: 'Skillsoft Sans', + weight: 500, + style: 'italic', + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-Bold`, + extensions, + name: 'Skillsoft Sans', + weight: 700, + }, + { + filePath: `${FONT_ASSET_PATH}/SkillsoftSans-BoldItalic`, + extensions, + name: 'Skillsoft Sans', + weight: 700, + style: 'italic', + }, +]; + +/** + * Roboto + Roboto Mono for Percipio `system` and `monospace` theme slots. + * The default weight for bold fonts is 700; Percipio uses 500 for the base bold. + */ +const percipioRobotoFonts: readonly FontConfig[] = [ { filePath: `${FONT_ASSET_PATH}/roboto-regular`, extensions, @@ -55,7 +140,6 @@ export const percipio: readonly FontConfig[] = [ name: 'Roboto', style: 'italic', }, - // The default weight for bold fonts is 700, Percipio uses 500 for the base bold { filePath: `${FONT_ASSET_PATH}/roboto-bold`, extensions, @@ -95,31 +179,10 @@ export const percipio: readonly FontConfig[] = [ }, ]; -export const lxStudio: readonly FontConfig[] = [ - { - filePath: `${FONT_ASSET_PATH}/hanken-grotesk-regular`, - extensions, - name: 'Hanken Grotesk', - }, - { - filePath: `${FONT_ASSET_PATH}/hanken-grotesk-italic`, - extensions, - name: 'Hanken Grotesk', - style: 'italic', - }, - { - filePath: `${FONT_ASSET_PATH}/hanken-grotesk-bold`, - extensions, - name: 'Hanken Grotesk', - weight: 'bold', - }, - { - filePath: `${FONT_ASSET_PATH}/hanken-grotesk-bold-italic`, - extensions, - name: 'Hanken Grotesk', - weight: 'bold', - style: 'italic', - }, +/** Percipio: Skillsoft (accent/base) plus Roboto system + Roboto Mono monospace. */ +export const percipio: readonly FontConfig[] = [ + ...skillsoftFamilyFonts, + ...percipioRobotoFonts, ]; -export const webFonts = { core, percipio, lxStudio } as const; +export const webFonts = { core, percipio, lxStudio: percipio } as const; diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap index d354180425d..eee6efdb42b 100644 --- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap +++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`themes admin - theme shape 1`] = ` { @@ -1032,6 +1032,1097 @@ monospace", } `; +exports[`themes lxStudio - theme shape 1`] = ` +{ + "_getColorValue": [Function], + "_tokens": { + "colors": { + "beige": "#FFF0E5", + "beige-100": "#FFF0E5", + "black": "#000000", + "blue": "#1557FF", + "blue-0": "#F5FCFF", + "blue-100": "#D3F2FF", + "blue-300": "#66C4FF", + "blue-400": "#3388FF", + "blue-500": "#1557FF", + "blue-800": "#1D2340", + "gray-100": "#F5F5F5", + "gray-200": "#EEEEEE", + "gray-300": "#E0E0E0", + "gray-600": "#9E9E9E", + "gray-800": "#616161", + "gray-900": "#424242", + "green": "#008A27", + "green-0": "#F5FFE3", + "green-100": "#EAFDC6", + "green-400": "#AEE938", + "green-700": "#008A27", + "green-900": "#151C07", + "hyper": "#3A10E5", + "hyper-400": "#5533FF", + "hyper-500": "#3A10E5", + "lightBlue": "#66C4FF", + "lightGreen": "#AEE938", + "lxStudioBgPrimary": "#FAFBFC", + "lxStudioSuccess": "#06844F", + "navy": "#10162F", + "navy-100": "rgba(16,22,47,0.04)", + "navy-200": "rgba(16,22,47,0.12)", + "navy-300": "rgba(16,22,47,0.28)", + "navy-400": "rgba(16,22,47,0.47)", + "navy-500": "rgba(16,22,47,0.63)", + "navy-600": "rgba(16,22,47,0.75)", + "navy-700": "rgba(16,22,47,0.86)", + "navy-800": "#10162F", + "navy-900": "#0A0D1C", + "orange": "#FF8C00", + "orange-100": "#FFE8CC", + "orange-500": "#FF8C00", + "paleBlue": "#F5FCFF", + "paleGreen": "#F5FFE3", + "palePink": "#FFF5FF", + "paleRed": "#DC5879", + "paleYellow": "#FFFAE5", + "pink": "#F966FF", + "pink-0": "#FFF5FF", + "pink-400": "#F966FF", + "red": "#E91C11", + "red-0": "#FBF1F0", + "red-300": "#E85D7F", + "red-400": "#DC5879", + "red-500": "#E91C11", + "red-600": "#BE1809", + "red-900": "#280503", + "sapphire": "#1C50BB", + "white": "#ffffff", + "white-100": "rgba(255,255,255,0.04)", + "white-200": "rgba(255,255,255,0.09)", + "white-300": "rgba(255,255,255,0.2)", + "white-400": "rgba(255,255,255,0.35)", + "white-500": "rgba(255,255,255,0.5)", + "white-600": "rgba(255,255,255,0.65)", + "white-700": "rgba(255,255,255,0.8)", + "yellow": "#FFD300", + "yellow-0": "#FFFAE5", + "yellow-400": "#CCA900", + "yellow-500": "#FFD300", + "yellow-900": "#211B00", + }, + "elements": { + "headerHeight": { + "base": "4rem", + "md": "5rem", + }, + "headerZ": 15, + }, + "modes": { + "dark": { + "background": "#10162F", + "background-contrast": "#000000", + "background-current": "#10162F", + "background-disabled": "rgba(255,255,255,0.09)", + "background-error": "#280503", + "background-hover": "rgba(255,255,255,0.09)", + "background-primary": "#0A0D1C", + "background-selected": "rgba(255,255,255,0.04)", + "background-success": "#151C07", + "background-warning": "#211B00", + "border-disabled": "rgba(255,255,255,0.5)", + "border-primary": "#ffffff", + "border-secondary": "rgba(255,255,255,0.65)", + "border-tertiary": "rgba(255,255,255,0.2)", + "danger": "#E85D7F", + "danger-hover": "#DC5879", + "feedback-error": "#E85D7F", + "feedback-success": "#AEE938", + "feedback-warning": "#FFFAE5", + "interface": "#FFD300", + "interface-hover": "#CCA900", + "primary": "#FFD300", + "primary-hover": "#CCA900", + "primary-inverse": "#3A10E5", + "secondary": "#ffffff", + "secondary-hover": "rgba(255,255,255,0.8)", + "shadow-primary": "#ffffff", + "shadow-secondary": "rgba(255,255,255,0.65)", + "text": "#ffffff", + "text-accent": "#FFF0E5", + "text-disabled": "rgba(255,255,255,0.5)", + "text-secondary": "rgba(255,255,255,0.65)", + }, + "light": { + "background": "#ffffff", + "background-contrast": "#ffffff", + "background-current": "#ffffff", + "background-disabled": "rgba(16,22,47,0.12)", + "background-error": "#FBF1F0", + "background-hover": "rgba(16,22,47,0.12)", + "background-primary": "#FAFBFC", + "background-selected": "rgba(16,22,47,0.04)", + "background-success": "#F5FFE3", + "background-warning": "#FFFAE5", + "border-disabled": "rgba(16,22,47,0.28)", + "border-primary": "rgba(16,22,47,0.47)", + "border-secondary": "rgba(16,22,47,0.75)", + "border-tertiary": "#10162F", + "danger": "#E91C11", + "danger-hover": "#BE1809", + "feedback-error": "#BE1809", + "feedback-success": "#06844F", + "feedback-warning": "#FFD300", + "interface": "#3A10E5", + "interface-hover": "#5533FF", + "primary": "#1C50BB", + "primary-hover": "#10162F", + "primary-inverse": "#FFD300", + "secondary": "#10162F", + "secondary-hover": "rgba(16,22,47,0.86)", + "shadow-primary": "rgba(16,22,47,0.12)", + "shadow-secondary": "rgba(16,22,47,0.75)", + "text": "#10162F", + "text-accent": "#0A0D1C", + "text-disabled": "rgba(16,22,47,0.63)", + "text-secondary": "rgba(16,22,47,0.75)", + }, + }, + }, + "_variables": { + "mode": { + "--color-background": "var(--color-white)", + "--color-background-contrast": "var(--color-white)", + "--color-background-current": "var(--color-white)", + "--color-background-disabled": "var(--color-navy-200)", + "--color-background-error": "var(--color-red-0)", + "--color-background-hover": "var(--color-navy-200)", + "--color-background-primary": "var(--color-lxStudioBgPrimary)", + "--color-background-selected": "var(--color-navy-100)", + "--color-background-success": "var(--color-green-0)", + "--color-background-warning": "var(--color-yellow-0)", + "--color-border-disabled": "var(--color-navy-300)", + "--color-border-primary": "var(--color-navy-400)", + "--color-border-secondary": "var(--color-navy-600)", + "--color-border-tertiary": "var(--color-navy-800)", + "--color-danger": "var(--color-red-500)", + "--color-danger-hover": "var(--color-red-600)", + "--color-feedback-error": "var(--color-red-600)", + "--color-feedback-success": "var(--color-lxStudioSuccess)", + "--color-feedback-warning": "var(--color-yellow)", + "--color-interface": "var(--color-hyper-500)", + "--color-interface-hover": "var(--color-hyper-400)", + "--color-primary": "var(--color-sapphire)", + "--color-primary-hover": "var(--color-navy-800)", + "--color-primary-inverse": "var(--color-yellow-500)", + "--color-secondary": "var(--color-navy-800)", + "--color-secondary-hover": "var(--color-navy-700)", + "--color-shadow-primary": "var(--color-navy-200)", + "--color-shadow-secondary": "var(--color-navy-600)", + "--color-text": "var(--color-navy-800)", + "--color-text-accent": "var(--color-navy-900)", + "--color-text-disabled": "var(--color-navy-500)", + "--color-text-secondary": "var(--color-navy-600)", + }, + "root": { + "--color-beige": "#FFF0E5", + "--color-beige-100": "#FFF0E5", + "--color-black": "#000000", + "--color-blue": "#1557FF", + "--color-blue-0": "#F5FCFF", + "--color-blue-100": "#D3F2FF", + "--color-blue-300": "#66C4FF", + "--color-blue-400": "#3388FF", + "--color-blue-500": "#1557FF", + "--color-blue-800": "#1D2340", + "--color-gray-100": "#F5F5F5", + "--color-gray-200": "#EEEEEE", + "--color-gray-300": "#E0E0E0", + "--color-gray-600": "#9E9E9E", + "--color-gray-800": "#616161", + "--color-gray-900": "#424242", + "--color-green": "#008A27", + "--color-green-0": "#F5FFE3", + "--color-green-100": "#EAFDC6", + "--color-green-400": "#AEE938", + "--color-green-700": "#008A27", + "--color-green-900": "#151C07", + "--color-hyper": "#3A10E5", + "--color-hyper-400": "#5533FF", + "--color-hyper-500": "#3A10E5", + "--color-lightBlue": "#66C4FF", + "--color-lightGreen": "#AEE938", + "--color-lxStudioBgPrimary": "#FAFBFC", + "--color-lxStudioSuccess": "#06844F", + "--color-navy": "#10162F", + "--color-navy-100": "rgba(16,22,47,0.04)", + "--color-navy-200": "rgba(16,22,47,0.12)", + "--color-navy-300": "rgba(16,22,47,0.28)", + "--color-navy-400": "rgba(16,22,47,0.47)", + "--color-navy-500": "rgba(16,22,47,0.63)", + "--color-navy-600": "rgba(16,22,47,0.75)", + "--color-navy-700": "rgba(16,22,47,0.86)", + "--color-navy-800": "#10162F", + "--color-navy-900": "#0A0D1C", + "--color-orange": "#FF8C00", + "--color-orange-100": "#FFE8CC", + "--color-orange-500": "#FF8C00", + "--color-paleBlue": "#F5FCFF", + "--color-paleGreen": "#F5FFE3", + "--color-palePink": "#FFF5FF", + "--color-paleRed": "#DC5879", + "--color-paleYellow": "#FFFAE5", + "--color-pink": "#F966FF", + "--color-pink-0": "#FFF5FF", + "--color-pink-400": "#F966FF", + "--color-red": "#E91C11", + "--color-red-0": "#FBF1F0", + "--color-red-300": "#E85D7F", + "--color-red-400": "#DC5879", + "--color-red-500": "#E91C11", + "--color-red-600": "#BE1809", + "--color-red-900": "#280503", + "--color-sapphire": "#1C50BB", + "--color-white": "#ffffff", + "--color-white-100": "rgba(255,255,255,0.04)", + "--color-white-200": "rgba(255,255,255,0.09)", + "--color-white-300": "rgba(255,255,255,0.2)", + "--color-white-400": "rgba(255,255,255,0.35)", + "--color-white-500": "rgba(255,255,255,0.5)", + "--color-white-600": "rgba(255,255,255,0.65)", + "--color-white-700": "rgba(255,255,255,0.8)", + "--color-yellow": "#FFD300", + "--color-yellow-0": "#FFFAE5", + "--color-yellow-400": "#CCA900", + "--color-yellow-500": "#FFD300", + "--color-yellow-900": "#211B00", + "--elements-headerHeight": "4rem", + "--elements-headerZ": 15, + "@media only screen and (min-width: 1024px)": { + "--elements-headerHeight": "5rem", + }, + }, + }, + "borderRadii": { + "full": "999px", + "lg": "12px", + "md": "8px", + "none": "0px", + "sm": "4px", + "xl": "16px", + }, + "borders": { + "1": "1px solid var(--color-border-primary)", + "2": "2px solid var(--color-border-primary)", + }, + "breakpoints": { + "c_base": "@container (min-width: 1px)", + "c_lg": "@container (min-width: 1200px)", + "c_md": "@container (min-width: 1024px)", + "c_sm": "@container (min-width: 768px)", + "c_xl": "@container (min-width: 1440px)", + "c_xs": "@container (min-width: 480px)", + "lg": "@media only screen and (min-width: 1200px)", + "md": "@media only screen and (min-width: 1024px)", + "sm": "@media only screen and (min-width: 768px)", + "xl": "@media only screen and (min-width: 1440px)", + "xs": "@media only screen and (min-width: 480px)", + }, + "colors": { + "background": "var(--color-background)", + "background-contrast": "var(--color-background-contrast)", + "background-current": "var(--color-background-current)", + "background-disabled": "var(--color-background-disabled)", + "background-error": "var(--color-background-error)", + "background-hover": "var(--color-background-hover)", + "background-primary": "var(--color-background-primary)", + "background-selected": "var(--color-background-selected)", + "background-success": "var(--color-background-success)", + "background-warning": "var(--color-background-warning)", + "beige": "var(--color-beige)", + "beige-100": "var(--color-beige-100)", + "black": "var(--color-black)", + "blue": "var(--color-blue)", + "blue-0": "var(--color-blue-0)", + "blue-100": "var(--color-blue-100)", + "blue-300": "var(--color-blue-300)", + "blue-400": "var(--color-blue-400)", + "blue-500": "var(--color-blue-500)", + "blue-800": "var(--color-blue-800)", + "border-disabled": "var(--color-border-disabled)", + "border-primary": "var(--color-border-primary)", + "border-secondary": "var(--color-border-secondary)", + "border-tertiary": "var(--color-border-tertiary)", + "danger": "var(--color-danger)", + "danger-hover": "var(--color-danger-hover)", + "feedback-error": "var(--color-feedback-error)", + "feedback-success": "var(--color-feedback-success)", + "feedback-warning": "var(--color-feedback-warning)", + "gray-100": "var(--color-gray-100)", + "gray-200": "var(--color-gray-200)", + "gray-300": "var(--color-gray-300)", + "gray-600": "var(--color-gray-600)", + "gray-800": "var(--color-gray-800)", + "gray-900": "var(--color-gray-900)", + "green": "var(--color-green)", + "green-0": "var(--color-green-0)", + "green-100": "var(--color-green-100)", + "green-400": "var(--color-green-400)", + "green-700": "var(--color-green-700)", + "green-900": "var(--color-green-900)", + "hyper": "var(--color-hyper)", + "hyper-400": "var(--color-hyper-400)", + "hyper-500": "var(--color-hyper-500)", + "interface": "var(--color-interface)", + "interface-hover": "var(--color-interface-hover)", + "lightBlue": "var(--color-lightBlue)", + "lightGreen": "var(--color-lightGreen)", + "lxStudioBgPrimary": "var(--color-lxStudioBgPrimary)", + "lxStudioSuccess": "var(--color-lxStudioSuccess)", + "navy": "var(--color-navy)", + "navy-100": "var(--color-navy-100)", + "navy-200": "var(--color-navy-200)", + "navy-300": "var(--color-navy-300)", + "navy-400": "var(--color-navy-400)", + "navy-500": "var(--color-navy-500)", + "navy-600": "var(--color-navy-600)", + "navy-700": "var(--color-navy-700)", + "navy-800": "var(--color-navy-800)", + "navy-900": "var(--color-navy-900)", + "orange": "var(--color-orange)", + "orange-100": "var(--color-orange-100)", + "orange-500": "var(--color-orange-500)", + "paleBlue": "var(--color-paleBlue)", + "paleGreen": "var(--color-paleGreen)", + "palePink": "var(--color-palePink)", + "paleRed": "var(--color-paleRed)", + "paleYellow": "var(--color-paleYellow)", + "pink": "var(--color-pink)", + "pink-0": "var(--color-pink-0)", + "pink-400": "var(--color-pink-400)", + "primary": "var(--color-primary)", + "primary-hover": "var(--color-primary-hover)", + "primary-inverse": "var(--color-primary-inverse)", + "red": "var(--color-red)", + "red-0": "var(--color-red-0)", + "red-300": "var(--color-red-300)", + "red-400": "var(--color-red-400)", + "red-500": "var(--color-red-500)", + "red-600": "var(--color-red-600)", + "red-900": "var(--color-red-900)", + "sapphire": "var(--color-sapphire)", + "secondary": "var(--color-secondary)", + "secondary-hover": "var(--color-secondary-hover)", + "shadow-primary": "var(--color-shadow-primary)", + "shadow-secondary": "var(--color-shadow-secondary)", + "text": "var(--color-text)", + "text-accent": "var(--color-text-accent)", + "text-disabled": "var(--color-text-disabled)", + "text-secondary": "var(--color-text-secondary)", + "white": "var(--color-white)", + "white-100": "var(--color-white-100)", + "white-200": "var(--color-white-200)", + "white-300": "var(--color-white-300)", + "white-400": "var(--color-white-400)", + "white-500": "var(--color-white-500)", + "white-600": "var(--color-white-600)", + "white-700": "var(--color-white-700)", + "yellow": "var(--color-yellow)", + "yellow-0": "var(--color-yellow-0)", + "yellow-400": "var(--color-yellow-400)", + "yellow-500": "var(--color-yellow-500)", + "yellow-900": "var(--color-yellow-900)", + }, + "elements": { + "headerHeight": "var(--elements-headerHeight)", + "headerZ": "var(--elements-headerZ)", + }, + "fontFamily": { + "accent": ""Skillsoft Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", +"Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", +sans-serif", + "base": ""Skillsoft Text", -apple-system, BlinkMacSystemFont, "Segoe UI", +"Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", +sans-serif", + "monospace": "Monaco, Menlo, "Ubuntu Mono", "Droid Sans Mono", Consolas, +monospace", + "system": "-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Ubuntu", +"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif", + }, + "fontSize": { + "14": "0.875rem", + "16": "1rem", + "18": "1.125rem", + "20": "1.25rem", + "22": "1.375rem", + "26": "1.625rem", + "34": "2.125rem", + "44": "2.75rem", + "64": "4rem", + }, + "fontWeight": { + "400": 400, + "500": 500, + "700": 700, + "base": 400, + "bold": 500, + "title": 500, + }, + "lineHeight": { + "base": 1.5, + "spacedTitle": 1.3, + "title": 1.2, + }, + "mode": "light", + "modes": { + "dark": { + "background": "navy-800", + "background-contrast": "black", + "background-current": "navy-800", + "background-disabled": "white-200", + "background-error": "red-900", + "background-hover": "white-200", + "background-primary": "navy-900", + "background-selected": "white-100", + "background-success": "green-900", + "background-warning": "yellow-900", + "border-disabled": "white-500", + "border-primary": "white", + "border-secondary": "white-600", + "border-tertiary": "white-300", + "danger": "red-300", + "danger-hover": "red-400", + "feedback-error": "red-300", + "feedback-success": "green-400", + "feedback-warning": "yellow-0", + "interface": "yellow-500", + "interface-hover": "yellow-400", + "primary": "yellow-500", + "primary-hover": "yellow-400", + "primary-inverse": "hyper-500", + "secondary": "white", + "secondary-hover": "white-700", + "shadow-primary": "white", + "shadow-secondary": "white-600", + "text": "white", + "text-accent": "beige", + "text-disabled": "white-500", + "text-secondary": "white-600", + }, + "light": { + "background": "white", + "background-contrast": "white", + "background-current": "white", + "background-disabled": "navy-200", + "background-error": "red-0", + "background-hover": "navy-200", + "background-primary": "lxStudioBgPrimary", + "background-selected": "navy-100", + "background-success": "green-0", + "background-warning": "yellow-0", + "border-disabled": "navy-300", + "border-primary": "navy-400", + "border-secondary": "navy-600", + "border-tertiary": "navy-800", + "danger": "red-500", + "danger-hover": "red-600", + "feedback-error": "red-600", + "feedback-success": "lxStudioSuccess", + "feedback-warning": "yellow", + "interface": "hyper-500", + "interface-hover": "hyper-400", + "primary": "sapphire", + "primary-hover": "navy-800", + "primary-inverse": "yellow-500", + "secondary": "navy-800", + "secondary-hover": "navy-700", + "shadow-primary": "navy-200", + "shadow-secondary": "navy-600", + "text": "navy-800", + "text-accent": "navy-900", + "text-disabled": "navy-500", + "text-secondary": "navy-600", + }, + }, + "name": "lxStudio", + "spacing": { + "0": 0, + "12": "0.75rem", + "16": "1rem", + "24": "1.5rem", + "32": "2rem", + "4": "0.25rem", + "40": "2.5rem", + "48": "3rem", + "64": "4rem", + "8": "0.5rem", + "96": "6rem", + }, +} +`; + +exports[`themes percipio - theme shape 1`] = ` +{ + "_getColorValue": [Function], + "_tokens": { + "colors": { + "beige": "#FFF0E5", + "beige-100": "#FFF0E5", + "black": "#000000", + "blue": "#1557FF", + "blue-0": "#F5FCFF", + "blue-100": "#D3F2FF", + "blue-300": "#66C4FF", + "blue-400": "#3388FF", + "blue-500": "#1557FF", + "blue-800": "#1D2340", + "gray-100": "#F5F5F5", + "gray-200": "#EEEEEE", + "gray-300": "#E0E0E0", + "gray-600": "#9E9E9E", + "gray-800": "#616161", + "gray-900": "#424242", + "green": "#008A27", + "green-0": "#F5FFE3", + "green-100": "#EAFDC6", + "green-400": "#AEE938", + "green-700": "#008A27", + "green-900": "#151C07", + "hyper": "#3A10E5", + "hyper-400": "#5533FF", + "hyper-500": "#3A10E5", + "lightBlue": "#66C4FF", + "lightGreen": "#AEE938", + "navy": "#10162F", + "navy-100": "rgba(16,22,47,0.04)", + "navy-200": "rgba(16,22,47,0.12)", + "navy-300": "rgba(16,22,47,0.28)", + "navy-400": "rgba(16,22,47,0.47)", + "navy-500": "rgba(16,22,47,0.63)", + "navy-600": "rgba(16,22,47,0.75)", + "navy-700": "rgba(16,22,47,0.86)", + "navy-800": "#10162F", + "navy-900": "#0A0D1C", + "orange": "#FF8C00", + "orange-100": "#FFE8CC", + "orange-500": "#FF8C00", + "paleBlue": "#F5FCFF", + "paleGreen": "#F5FFE3", + "palePink": "#FFF5FF", + "paleRed": "#DC5879", + "paleYellow": "#FFFAE5", + "percipioActionDangerHover": "#A52020", + "percipioActionPrimaryHover": "#141C36", + "percipioActionSecondary": "#6A6E75", + "percipioActionSecondaryHover": "rgba(106, 110, 117, 0.86)", + "percipioBgError": "#FFF1F5", + "percipioBgPrimary": "#FAFBFC", + "percipioBgSuccess": "#EEF7F3", + "percipioBgWarning": "#FFF7E0", + "percipioDanger": "#B83C3C", + "percipioFeedbackSuccess": "#1B8057", + "percipioFeedbackWarning": "#EF5B0D", + "percipioTextAccent": "#222325", + "percipioTextDisabled": "#AFB6C2", + "percipioTextPrimary": "#222325", + "percipioTextSecondary": "rgba(34, 35, 37, 0.75)", + "pink": "#F966FF", + "pink-0": "#FFF5FF", + "pink-400": "#F966FF", + "red": "#E91C11", + "red-0": "#FBF1F0", + "red-300": "#E85D7F", + "red-400": "#DC5879", + "red-500": "#E91C11", + "red-600": "#BE1809", + "red-900": "#280503", + "sapphire": "#1C50BB", + "white": "#ffffff", + "white-100": "rgba(255,255,255,0.04)", + "white-200": "rgba(255,255,255,0.09)", + "white-300": "rgba(255,255,255,0.2)", + "white-400": "rgba(255,255,255,0.35)", + "white-500": "rgba(255,255,255,0.5)", + "white-600": "rgba(255,255,255,0.65)", + "white-700": "rgba(255,255,255,0.8)", + "yellow": "#FFD300", + "yellow-0": "#FFFAE5", + "yellow-400": "#CCA900", + "yellow-500": "#FFD300", + "yellow-900": "#211B00", + }, + "elements": { + "headerHeight": { + "base": "4rem", + "md": "5rem", + }, + "headerZ": 15, + }, + "modes": { + "dark": { + "background": "#10162F", + "background-contrast": "#000000", + "background-current": "#10162F", + "background-disabled": "rgba(255,255,255,0.09)", + "background-error": "#280503", + "background-hover": "rgba(255,255,255,0.09)", + "background-primary": "#0A0D1C", + "background-selected": "rgba(255,255,255,0.04)", + "background-success": "#151C07", + "background-warning": "#211B00", + "border-disabled": "rgba(255,255,255,0.5)", + "border-primary": "#ffffff", + "border-secondary": "rgba(255,255,255,0.65)", + "border-tertiary": "rgba(255,255,255,0.2)", + "danger": "#E85D7F", + "danger-hover": "#DC5879", + "feedback-error": "#E85D7F", + "feedback-success": "#AEE938", + "feedback-warning": "#FFFAE5", + "interface": "#FFD300", + "interface-hover": "#CCA900", + "primary": "#FFD300", + "primary-hover": "#CCA900", + "primary-inverse": "#3A10E5", + "secondary": "#ffffff", + "secondary-hover": "rgba(255,255,255,0.8)", + "shadow-primary": "#ffffff", + "shadow-secondary": "rgba(255,255,255,0.65)", + "text": "#ffffff", + "text-accent": "#FFF0E5", + "text-disabled": "rgba(255,255,255,0.5)", + "text-secondary": "rgba(255,255,255,0.65)", + }, + "light": { + "background": "#ffffff", + "background-contrast": "#ffffff", + "background-current": "#ffffff", + "background-disabled": "rgba(16,22,47,0.12)", + "background-error": "#FFF1F5", + "background-hover": "rgba(16,22,47,0.12)", + "background-primary": "#FAFBFC", + "background-selected": "rgba(16,22,47,0.04)", + "background-success": "#EEF7F3", + "background-warning": "#FFF7E0", + "border-disabled": "rgba(16,22,47,0.28)", + "border-primary": "rgba(16,22,47,0.47)", + "border-secondary": "rgba(16,22,47,0.12)", + "border-tertiary": "#10162F", + "danger": "#B83C3C", + "danger-hover": "#A52020", + "feedback-error": "#B83C3C", + "feedback-success": "#1B8057", + "feedback-warning": "#EF5B0D", + "interface": "#1C50BB", + "interface-hover": "#141C36", + "primary": "#1C50BB", + "primary-hover": "#141C36", + "primary-inverse": "#ffffff", + "secondary": "#6A6E75", + "secondary-hover": "rgba(106, 110, 117, 0.86)", + "shadow-primary": "rgba(16,22,47,0.12)", + "shadow-secondary": "rgba(16,22,47,0.47)", + "text": "#222325", + "text-accent": "#222325", + "text-disabled": "#AFB6C2", + "text-secondary": "rgba(34, 35, 37, 0.75)", + }, + }, + }, + "_variables": { + "mode": { + "--color-background": "var(--color-white)", + "--color-background-contrast": "var(--color-white)", + "--color-background-current": "var(--color-white)", + "--color-background-disabled": "var(--color-navy-200)", + "--color-background-error": "var(--color-percipioBgError)", + "--color-background-hover": "var(--color-navy-200)", + "--color-background-primary": "var(--color-percipioBgPrimary)", + "--color-background-selected": "var(--color-navy-100)", + "--color-background-success": "var(--color-percipioBgSuccess)", + "--color-background-warning": "var(--color-percipioBgWarning)", + "--color-border-disabled": "var(--color-navy-300)", + "--color-border-primary": "var(--color-navy-400)", + "--color-border-secondary": "var(--color-navy-200)", + "--color-border-tertiary": "var(--color-navy-800)", + "--color-danger": "var(--color-percipioDanger)", + "--color-danger-hover": "var(--color-percipioActionDangerHover)", + "--color-feedback-error": "var(--color-percipioDanger)", + "--color-feedback-success": "var(--color-percipioFeedbackSuccess)", + "--color-feedback-warning": "var(--color-percipioFeedbackWarning)", + "--color-interface": "var(--color-sapphire)", + "--color-interface-hover": "var(--color-percipioActionPrimaryHover)", + "--color-primary": "var(--color-sapphire)", + "--color-primary-hover": "var(--color-percipioActionPrimaryHover)", + "--color-primary-inverse": "var(--color-white)", + "--color-secondary": "var(--color-percipioActionSecondary)", + "--color-secondary-hover": "var(--color-percipioActionSecondaryHover)", + "--color-shadow-primary": "var(--color-navy-200)", + "--color-shadow-secondary": "var(--color-navy-400)", + "--color-text": "var(--color-percipioTextPrimary)", + "--color-text-accent": "var(--color-percipioTextAccent)", + "--color-text-disabled": "var(--color-percipioTextDisabled)", + "--color-text-secondary": "var(--color-percipioTextSecondary)", + }, + "root": { + "--color-beige": "#FFF0E5", + "--color-beige-100": "#FFF0E5", + "--color-black": "#000000", + "--color-blue": "#1557FF", + "--color-blue-0": "#F5FCFF", + "--color-blue-100": "#D3F2FF", + "--color-blue-300": "#66C4FF", + "--color-blue-400": "#3388FF", + "--color-blue-500": "#1557FF", + "--color-blue-800": "#1D2340", + "--color-gray-100": "#F5F5F5", + "--color-gray-200": "#EEEEEE", + "--color-gray-300": "#E0E0E0", + "--color-gray-600": "#9E9E9E", + "--color-gray-800": "#616161", + "--color-gray-900": "#424242", + "--color-green": "#008A27", + "--color-green-0": "#F5FFE3", + "--color-green-100": "#EAFDC6", + "--color-green-400": "#AEE938", + "--color-green-700": "#008A27", + "--color-green-900": "#151C07", + "--color-hyper": "#3A10E5", + "--color-hyper-400": "#5533FF", + "--color-hyper-500": "#3A10E5", + "--color-lightBlue": "#66C4FF", + "--color-lightGreen": "#AEE938", + "--color-navy": "#10162F", + "--color-navy-100": "rgba(16,22,47,0.04)", + "--color-navy-200": "rgba(16,22,47,0.12)", + "--color-navy-300": "rgba(16,22,47,0.28)", + "--color-navy-400": "rgba(16,22,47,0.47)", + "--color-navy-500": "rgba(16,22,47,0.63)", + "--color-navy-600": "rgba(16,22,47,0.75)", + "--color-navy-700": "rgba(16,22,47,0.86)", + "--color-navy-800": "#10162F", + "--color-navy-900": "#0A0D1C", + "--color-orange": "#FF8C00", + "--color-orange-100": "#FFE8CC", + "--color-orange-500": "#FF8C00", + "--color-paleBlue": "#F5FCFF", + "--color-paleGreen": "#F5FFE3", + "--color-palePink": "#FFF5FF", + "--color-paleRed": "#DC5879", + "--color-paleYellow": "#FFFAE5", + "--color-percipioActionDangerHover": "#A52020", + "--color-percipioActionPrimaryHover": "#141C36", + "--color-percipioActionSecondary": "#6A6E75", + "--color-percipioActionSecondaryHover": "rgba(106, 110, 117, 0.86)", + "--color-percipioBgError": "#FFF1F5", + "--color-percipioBgPrimary": "#FAFBFC", + "--color-percipioBgSuccess": "#EEF7F3", + "--color-percipioBgWarning": "#FFF7E0", + "--color-percipioDanger": "#B83C3C", + "--color-percipioFeedbackSuccess": "#1B8057", + "--color-percipioFeedbackWarning": "#EF5B0D", + "--color-percipioTextAccent": "#222325", + "--color-percipioTextDisabled": "#AFB6C2", + "--color-percipioTextPrimary": "#222325", + "--color-percipioTextSecondary": "rgba(34, 35, 37, 0.75)", + "--color-pink": "#F966FF", + "--color-pink-0": "#FFF5FF", + "--color-pink-400": "#F966FF", + "--color-red": "#E91C11", + "--color-red-0": "#FBF1F0", + "--color-red-300": "#E85D7F", + "--color-red-400": "#DC5879", + "--color-red-500": "#E91C11", + "--color-red-600": "#BE1809", + "--color-red-900": "#280503", + "--color-sapphire": "#1C50BB", + "--color-white": "#ffffff", + "--color-white-100": "rgba(255,255,255,0.04)", + "--color-white-200": "rgba(255,255,255,0.09)", + "--color-white-300": "rgba(255,255,255,0.2)", + "--color-white-400": "rgba(255,255,255,0.35)", + "--color-white-500": "rgba(255,255,255,0.5)", + "--color-white-600": "rgba(255,255,255,0.65)", + "--color-white-700": "rgba(255,255,255,0.8)", + "--color-yellow": "#FFD300", + "--color-yellow-0": "#FFFAE5", + "--color-yellow-400": "#CCA900", + "--color-yellow-500": "#FFD300", + "--color-yellow-900": "#211B00", + "--elements-headerHeight": "4rem", + "--elements-headerZ": 15, + "@media only screen and (min-width: 1024px)": { + "--elements-headerHeight": "5rem", + }, + }, + }, + "borderRadii": { + "full": "999px", + "lg": "8px", + "md": "4px", + "none": "0px", + "sm": "2px", + "xl": "16px", + }, + "borders": { + "1": "1px solid var(--color-border-primary)", + "2": "2px solid var(--color-border-primary)", + }, + "breakpoints": { + "c_base": "@container (min-width: 1px)", + "c_lg": "@container (min-width: 1200px)", + "c_md": "@container (min-width: 1024px)", + "c_sm": "@container (min-width: 768px)", + "c_xl": "@container (min-width: 1440px)", + "c_xs": "@container (min-width: 480px)", + "lg": "@media only screen and (min-width: 1200px)", + "md": "@media only screen and (min-width: 1024px)", + "sm": "@media only screen and (min-width: 768px)", + "xl": "@media only screen and (min-width: 1440px)", + "xs": "@media only screen and (min-width: 480px)", + }, + "colors": { + "background": "var(--color-background)", + "background-contrast": "var(--color-background-contrast)", + "background-current": "var(--color-background-current)", + "background-disabled": "var(--color-background-disabled)", + "background-error": "var(--color-background-error)", + "background-hover": "var(--color-background-hover)", + "background-primary": "var(--color-background-primary)", + "background-selected": "var(--color-background-selected)", + "background-success": "var(--color-background-success)", + "background-warning": "var(--color-background-warning)", + "beige": "var(--color-beige)", + "beige-100": "var(--color-beige-100)", + "black": "var(--color-black)", + "blue": "var(--color-blue)", + "blue-0": "var(--color-blue-0)", + "blue-100": "var(--color-blue-100)", + "blue-300": "var(--color-blue-300)", + "blue-400": "var(--color-blue-400)", + "blue-500": "var(--color-blue-500)", + "blue-800": "var(--color-blue-800)", + "border-disabled": "var(--color-border-disabled)", + "border-primary": "var(--color-border-primary)", + "border-secondary": "var(--color-border-secondary)", + "border-tertiary": "var(--color-border-tertiary)", + "danger": "var(--color-danger)", + "danger-hover": "var(--color-danger-hover)", + "feedback-error": "var(--color-feedback-error)", + "feedback-success": "var(--color-feedback-success)", + "feedback-warning": "var(--color-feedback-warning)", + "gray-100": "var(--color-gray-100)", + "gray-200": "var(--color-gray-200)", + "gray-300": "var(--color-gray-300)", + "gray-600": "var(--color-gray-600)", + "gray-800": "var(--color-gray-800)", + "gray-900": "var(--color-gray-900)", + "green": "var(--color-green)", + "green-0": "var(--color-green-0)", + "green-100": "var(--color-green-100)", + "green-400": "var(--color-green-400)", + "green-700": "var(--color-green-700)", + "green-900": "var(--color-green-900)", + "hyper": "var(--color-hyper)", + "hyper-400": "var(--color-hyper-400)", + "hyper-500": "var(--color-hyper-500)", + "interface": "var(--color-interface)", + "interface-hover": "var(--color-interface-hover)", + "lightBlue": "var(--color-lightBlue)", + "lightGreen": "var(--color-lightGreen)", + "navy": "var(--color-navy)", + "navy-100": "var(--color-navy-100)", + "navy-200": "var(--color-navy-200)", + "navy-300": "var(--color-navy-300)", + "navy-400": "var(--color-navy-400)", + "navy-500": "var(--color-navy-500)", + "navy-600": "var(--color-navy-600)", + "navy-700": "var(--color-navy-700)", + "navy-800": "var(--color-navy-800)", + "navy-900": "var(--color-navy-900)", + "orange": "var(--color-orange)", + "orange-100": "var(--color-orange-100)", + "orange-500": "var(--color-orange-500)", + "paleBlue": "var(--color-paleBlue)", + "paleGreen": "var(--color-paleGreen)", + "palePink": "var(--color-palePink)", + "paleRed": "var(--color-paleRed)", + "paleYellow": "var(--color-paleYellow)", + "percipioActionDangerHover": "var(--color-percipioActionDangerHover)", + "percipioActionPrimaryHover": "var(--color-percipioActionPrimaryHover)", + "percipioActionSecondary": "var(--color-percipioActionSecondary)", + "percipioActionSecondaryHover": "var(--color-percipioActionSecondaryHover)", + "percipioBgError": "var(--color-percipioBgError)", + "percipioBgPrimary": "var(--color-percipioBgPrimary)", + "percipioBgSuccess": "var(--color-percipioBgSuccess)", + "percipioBgWarning": "var(--color-percipioBgWarning)", + "percipioDanger": "var(--color-percipioDanger)", + "percipioFeedbackSuccess": "var(--color-percipioFeedbackSuccess)", + "percipioFeedbackWarning": "var(--color-percipioFeedbackWarning)", + "percipioTextAccent": "var(--color-percipioTextAccent)", + "percipioTextDisabled": "var(--color-percipioTextDisabled)", + "percipioTextPrimary": "var(--color-percipioTextPrimary)", + "percipioTextSecondary": "var(--color-percipioTextSecondary)", + "pink": "var(--color-pink)", + "pink-0": "var(--color-pink-0)", + "pink-400": "var(--color-pink-400)", + "primary": "var(--color-primary)", + "primary-hover": "var(--color-primary-hover)", + "primary-inverse": "var(--color-primary-inverse)", + "red": "var(--color-red)", + "red-0": "var(--color-red-0)", + "red-300": "var(--color-red-300)", + "red-400": "var(--color-red-400)", + "red-500": "var(--color-red-500)", + "red-600": "var(--color-red-600)", + "red-900": "var(--color-red-900)", + "sapphire": "var(--color-sapphire)", + "secondary": "var(--color-secondary)", + "secondary-hover": "var(--color-secondary-hover)", + "shadow-primary": "var(--color-shadow-primary)", + "shadow-secondary": "var(--color-shadow-secondary)", + "text": "var(--color-text)", + "text-accent": "var(--color-text-accent)", + "text-disabled": "var(--color-text-disabled)", + "text-secondary": "var(--color-text-secondary)", + "white": "var(--color-white)", + "white-100": "var(--color-white-100)", + "white-200": "var(--color-white-200)", + "white-300": "var(--color-white-300)", + "white-400": "var(--color-white-400)", + "white-500": "var(--color-white-500)", + "white-600": "var(--color-white-600)", + "white-700": "var(--color-white-700)", + "yellow": "var(--color-yellow)", + "yellow-0": "var(--color-yellow-0)", + "yellow-400": "var(--color-yellow-400)", + "yellow-500": "var(--color-yellow-500)", + "yellow-900": "var(--color-yellow-900)", + }, + "elements": { + "headerHeight": "var(--elements-headerHeight)", + "headerZ": "var(--elements-headerZ)", + }, + "fontFamily": { + "accent": ""Skillsoft Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", +"Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", +sans-serif", + "base": ""Skillsoft Text", -apple-system, BlinkMacSystemFont, "Segoe UI", +"Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", +sans-serif", + "monospace": ""Roboto Mono", monospace", + "system": ""Roboto", sans-serif", + }, + "fontSize": { + "14": "0.875rem", + "16": "1rem", + "18": "1.125rem", + "20": "1.25rem", + "22": "1.375rem", + "26": "1.625rem", + "34": "2.125rem", + "44": "2.75rem", + "64": "4rem", + }, + "fontWeight": { + "400": 400, + "500": 500, + "700": 700, + "base": 400, + "bold": 500, + "title": 500, + }, + "lineHeight": { + "base": 1.5, + "spacedTitle": 1.3, + "title": 1.2, + }, + "mode": "light", + "modes": { + "dark": { + "background": "navy-800", + "background-contrast": "black", + "background-current": "navy-800", + "background-disabled": "white-200", + "background-error": "red-900", + "background-hover": "white-200", + "background-primary": "navy-900", + "background-selected": "white-100", + "background-success": "green-900", + "background-warning": "yellow-900", + "border-disabled": "white-500", + "border-primary": "white", + "border-secondary": "white-600", + "border-tertiary": "white-300", + "danger": "red-300", + "danger-hover": "red-400", + "feedback-error": "red-300", + "feedback-success": "green-400", + "feedback-warning": "yellow-0", + "interface": "yellow-500", + "interface-hover": "yellow-400", + "primary": "yellow-500", + "primary-hover": "yellow-400", + "primary-inverse": "hyper-500", + "secondary": "white", + "secondary-hover": "white-700", + "shadow-primary": "white", + "shadow-secondary": "white-600", + "text": "white", + "text-accent": "beige", + "text-disabled": "white-500", + "text-secondary": "white-600", + }, + "light": { + "background": "white", + "background-contrast": "white", + "background-current": "white", + "background-disabled": "navy-200", + "background-error": "percipioBgError", + "background-hover": "navy-200", + "background-primary": "percipioBgPrimary", + "background-selected": "navy-100", + "background-success": "percipioBgSuccess", + "background-warning": "percipioBgWarning", + "border-disabled": "navy-300", + "border-primary": "navy-400", + "border-secondary": "navy-200", + "border-tertiary": "navy-800", + "danger": "percipioDanger", + "danger-hover": "percipioActionDangerHover", + "feedback-error": "percipioDanger", + "feedback-success": "percipioFeedbackSuccess", + "feedback-warning": "percipioFeedbackWarning", + "interface": "sapphire", + "interface-hover": "percipioActionPrimaryHover", + "primary": "sapphire", + "primary-hover": "percipioActionPrimaryHover", + "primary-inverse": "white", + "secondary": "percipioActionSecondary", + "secondary-hover": "percipioActionSecondaryHover", + "shadow-primary": "navy-200", + "shadow-secondary": "navy-400", + "text": "percipioTextPrimary", + "text-accent": "percipioTextAccent", + "text-disabled": "percipioTextDisabled", + "text-secondary": "percipioTextSecondary", + }, + }, + "name": "percipio", + "spacing": { + "0": 0, + "12": "0.75rem", + "16": "1rem", + "24": "1.5rem", + "32": "2rem", + "4": "0.25rem", + "40": "2.5rem", + "48": "3rem", + "64": "4rem", + "8": "0.5rem", + "96": "6rem", + }, +} +`; + exports[`themes platform - theme shape 1`] = ` { "_getColorValue": [Function], diff --git a/packages/gamut-styles/src/themes/__tests__/theme.test.ts b/packages/gamut-styles/src/themes/__tests__/theme.test.ts index 8ad761e01b8..62399747199 100644 --- a/packages/gamut-styles/src/themes/__tests__/theme.test.ts +++ b/packages/gamut-styles/src/themes/__tests__/theme.test.ts @@ -1,5 +1,7 @@ import { adminTheme } from '../admin'; import { coreTheme } from '../core'; +import { lxStudioTheme } from '../lxStudio'; +import { percipioTheme } from '../percipio'; import { platformTheme } from '../platform'; describe('themes', () => { @@ -7,5 +9,7 @@ describe('themes', () => { ['core', coreTheme], ['platform', platformTheme], ['admin', adminTheme], + ['lxStudio', lxStudioTheme], + ['percipio', percipioTheme], ])(`%s - theme shape`, (_, theme) => expect(theme).toMatchSnapshot()); }); diff --git a/packages/gamut-styles/src/themes/lxStudio.ts b/packages/gamut-styles/src/themes/lxStudio.ts index b37c060884c..7b787cdc4fb 100644 --- a/packages/gamut-styles/src/themes/lxStudio.ts +++ b/packages/gamut-styles/src/themes/lxStudio.ts @@ -1,10 +1,11 @@ import { createTheme } from '@codecademy/variance'; import { - fontLxStudio, fontMonospace, fontSystem, + fontWeightMediumTitle, lxStudioPalette, + percipioFontFamily, } from '../variables'; import { coreTheme } from './core'; @@ -14,8 +15,8 @@ import { coreTheme } from './core'; */ const lxStudioFontFamily = { - accent: fontLxStudio, - base: fontLxStudio, + accent: percipioFontFamily.accent, + base: percipioFontFamily.base, monospace: fontMonospace, system: fontSystem, } as const; @@ -33,6 +34,7 @@ export const lxStudioTheme = createTheme({ ...coreTheme, borderRadii: lxStudioBorderRadii, fontFamily: lxStudioFontFamily, + fontWeight: fontWeightMediumTitle, }) .addColors(lxStudioPalette) .addColorModes('light', { @@ -48,8 +50,8 @@ export const lxStudioTheme = createTheme({ primary: 'navy-200', }, primary: { - _: 'lxStudioPurple', - hover: 'lxStudioPurpleHover', + _: 'sapphire', + hover: 'navy-800', }, interface: { _: 'hyper-500', @@ -67,4 +69,4 @@ export const lxStudioTheme = createTheme({ export type LxStudioThemeShape = typeof lxStudioTheme; -export interface LxStudioTheme extends LxStudioThemeShape {} +export type LxStudioTheme = LxStudioThemeShape; diff --git a/packages/gamut-styles/src/themes/percipio.ts b/packages/gamut-styles/src/themes/percipio.ts index f5903dc904c..8ad0013945d 100644 --- a/packages/gamut-styles/src/themes/percipio.ts +++ b/packages/gamut-styles/src/themes/percipio.ts @@ -1,18 +1,16 @@ import { createTheme } from '@codecademy/variance'; -import { percipioFontFamily, percipioPalette } from '../variables'; +import { + fontWeightMediumTitle, + percipioFontFamily, + percipioPalette, +} from '../variables'; import { coreTheme } from './core'; export const percipioTheme = createTheme({ ...coreTheme, fontFamily: percipioFontFamily, - fontWeight: { - base: 400, - title: 500, - bold: 500, - 700: 700, - 400: 400, - }, + fontWeight: fontWeightMediumTitle, }) .addColors(percipioPalette) .addColorModes('light', { @@ -40,7 +38,7 @@ export const percipioTheme = createTheme({ secondary: 'navy-400', }, primary: { - _: 'percipioActionPrimary', + _: 'sapphire', hover: 'percipioActionPrimaryHover', inverse: 'white', }, @@ -53,7 +51,7 @@ export const percipioTheme = createTheme({ hover: 'percipioActionDangerHover', }, interface: { - _: 'percipioActionPrimary', + _: 'sapphire', hover: 'percipioActionPrimaryHover', }, border: { diff --git a/packages/gamut-styles/src/utilities/__tests__/fontUtils.test.ts b/packages/gamut-styles/src/utilities/__tests__/fontUtils.test.ts index f10a302a0ed..75880cdfd93 100644 --- a/packages/gamut-styles/src/utilities/__tests__/fontUtils.test.ts +++ b/packages/gamut-styles/src/utilities/__tests__/fontUtils.test.ts @@ -1,36 +1,39 @@ import { webFonts } from '../../remoteAssets/fonts'; import { getFonts } from '../fontUtils'; -jest.mock('../../remoteAssets/fonts', () => ({ - webFonts: { - core: [ - { - filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', - extensions: ['woff2', 'woff'], - name: 'Apercu', - }, - { - filePath: 'https://www.codecademy.com/gamut/apercu-bold-pro', - extensions: ['woff2', 'woff'], - name: 'Apercu', - weight: 'bold', - }, - ], - percipio: [ - { - filePath: 'https://www.codecademy.com/gamut/roboto-regular', - extensions: ['woff2', 'woff'], - name: 'Roboto', - }, - { - filePath: 'https://www.codecademy.com/gamut/roboto-bold', - extensions: ['woff2', 'woff'], - name: 'Roboto', - weight: 'bold', - }, - ], - }, -})); +jest.mock('../../remoteAssets/fonts', () => { + const percipio = [ + { + filePath: 'https://www.codecademy.com/gamut/SkillsoftText-Regular', + extensions: ['woff2', 'woff'], + name: 'Skillsoft Text', + }, + { + filePath: 'https://www.codecademy.com/gamut/roboto-regular', + extensions: ['woff2', 'woff'], + name: 'Roboto', + }, + ]; + return { + webFonts: { + core: [ + { + filePath: 'https://www.codecademy.com/gamut/apercu-regular-pro', + extensions: ['woff2', 'woff'], + name: 'Apercu', + }, + { + filePath: 'https://www.codecademy.com/gamut/apercu-bold-pro', + extensions: ['woff2', 'woff'], + name: 'Apercu', + weight: 'bold', + }, + ], + percipio, + lxStudio: percipio, + }, + }; +}); describe('fontUtils', () => { describe('getFonts', () => { @@ -55,6 +58,11 @@ describe('fontUtils', () => { expect(fonts).toBe(webFonts.percipio); }); + it('should return percipio fonts for lxStudio theme', () => { + const fonts = getFonts('lxStudio'); + expect(fonts).toBe(webFonts.percipio); + }); + it('should return core fonts for core theme', () => { const fonts = getFonts('core'); expect(fonts).toBe(webFonts.core); @@ -99,6 +107,7 @@ describe('fontUtils', () => { webFonts: { core: undefined, percipio: webFonts.percipio, + lxStudio: webFonts.percipio, }, })); diff --git a/packages/gamut-styles/src/variables/colors.ts b/packages/gamut-styles/src/variables/colors.ts index 51196adbb6f..38001718876 100644 --- a/packages/gamut-styles/src/variables/colors.ts +++ b/packages/gamut-styles/src/variables/colors.ts @@ -164,11 +164,13 @@ export const platformPalette = { * LX Studio Colors */ +/** Primary brand blue; used by LX Studio and Percipio theme `primary` tokens */ +export const sapphire = '#1C50BB'; + export const lxStudioColors = { lxStudioSuccess: '#06844F', lxStudioBgPrimary: '#FAFBFC', - lxStudioPurple: '#5628FE', - lxStudioPurpleHover: '#7955FC', + sapphire, } as const; export const lxStudioPalette = { @@ -194,7 +196,7 @@ export const percipioColors = { percipioBgError: '#FFF1F5', // Action colors - percipioActionPrimary: '#0073C4', + sapphire, percipioActionPrimaryHover: '#141C36', percipioActionSecondary: '#6A6E75', percipioActionSecondaryHover: 'rgba(106, 110, 117, 0.86)', @@ -206,6 +208,8 @@ export const percipioColors = { // Multiuse colors percipioDanger: '#B83C3C', + + /** Shared with LX Studio; `primary` color mode resolves here */ } as const; export const percipioPalette = { diff --git a/packages/gamut-styles/src/variables/deprecated-colors.ts b/packages/gamut-styles/src/variables/deprecated-colors.ts deleted file mode 100644 index 30594995c0b..00000000000 --- a/packages/gamut-styles/src/variables/deprecated-colors.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { flattenScale } from '@codecademy/variance'; - -import { corePalette } from './colors'; - -const { black, white } = corePalette; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -export const interactiveColors = { - dark: corePalette.hyper, - light: corePalette.yellow, -} as const; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -export const editorColors = { - blue: '#83fff5', - deepPurple: '#cc7bc2', - gray: '#939598', - green: '#b4d353', - orange: '#ff8973', - purple: '#b3ccff', - red: '#ea6c8b', - yellow: '#ffe083', -} as const; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -export const platformColors = { - mint: { - '500': '#37c3be', - '600': '#2c9c98', - '700': '#217572', - }, - purple: { - '200': '#c3c1d7', - '300': '#a5a1c2', - '400': '#8782ae', - '500': '#69639a', - '600': '#544f7b', - '700': '#3f3b5c', - '800': '#2a283e', - '900': '#15141f', - }, -} as const; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -export const swatches = { - beige: { - '0': '#FFF0E5', - }, - blue: { - '0': '#F5FCFF', - '300': '#66C4FF', - '500': '#1557FF', - '800': '#1D2340', - '900': '#10162f', - }, - green: { - '0': '#F5FFE3', - '400': '#AEE938', - '700': '#008A27', - }, - yellow: { - '0': '#FFFAE5', - '400': '#CCA900', - '500': '#FFD300', - }, - pink: { - '0': '#FFF5FF', - '400': '#F966FF', - }, - red: { - '500': '#E91C11', - }, - orange: { - '500': '#FF8C00', - }, - hyper: { - '400': '#5533FF', - '500': '#3A10E5', - }, - gray: { - '0': white, - '100': '#f6f5fa', - '200': '#dddce0', - '300': '#c4c3c7', - '400': '#a2a2a6', - '500': '#828285', - '600': '#646466', - '700': '#4b4b4d', - '800': '#323233', - '900': '#19191a', - }, -} as const; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -const trueColors = { - beige: swatches.beige[0], - blue: swatches.blue[500], - green: swatches.green[700], - hyper: swatches.hyper[500], - lightBlue: swatches.blue[300], - lightGreen: swatches.green[400], - navy: swatches.blue[900], - orange: swatches.orange[500], - paleBlue: swatches.blue[0], - paleGreen: swatches.green[0], - palePink: swatches.pink[0], - paleYellow: swatches.yellow[0], - pink: swatches.pink[400], - red: swatches.red[500], - yellow: swatches.yellow[500], - black, - white, -} as const; - -/** - * @deprecated - * Using these variables directly is no longer supported. - * - * Please use [`theme.colors`](https://gamut.codecademy.com/storybook/?path=/docs/foundations-theme--colors#standard-colors) - */ - -export const colors = { - ...flattenScale(swatches), - ...trueColors, -} as const; diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts index 6a43dc3d8d1..d4f074462ef 100644 --- a/packages/gamut-styles/src/variables/index.ts +++ b/packages/gamut-styles/src/variables/index.ts @@ -5,5 +5,3 @@ export * from './responsive'; export * from './spacing'; export * from './timing'; export * from './typography'; -// Deprecated variables -export * from './deprecated-colors'; diff --git a/packages/gamut-styles/src/variables/typography.ts b/packages/gamut-styles/src/variables/typography.ts index e096c1b342a..2bd2b4001c1 100644 --- a/packages/gamut-styles/src/variables/typography.ts +++ b/packages/gamut-styles/src/variables/typography.ts @@ -14,7 +14,11 @@ monospace`; export const fontSystem = `-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`; -export const fontLxStudio = `"Hanken Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", +export const fontPercipioAccent = `"Skillsoft Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", +"Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", +sans-serif`; + +export const fontPercipioBase = `"Skillsoft Text", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`; @@ -50,9 +54,17 @@ export const fontWeight = { 400: 400, } as const; +/** Title/bold semantic tokens use 500 (Medium), matching Skillsoft + Percipio Roboto webfonts. */ +export const fontWeightMediumTitle = { + ...fontWeight, + title: 500, + bold: 500, + 500: 500, +} as const; + export const percipioFontFamily = { - accent: '"Roboto", sans-serif', - base: '"Roboto", sans-serif', + accent: fontPercipioAccent, + base: fontPercipioBase, monospace: '"Roboto Mono", monospace', system: '"Roboto", sans-serif', } as const; diff --git a/packages/gamut-styles/utils/variables/index.js b/packages/gamut-styles/utils/variables/index.js deleted file mode 100644 index 88af9701f32..00000000000 --- a/packages/gamut-styles/utils/variables/index.js +++ /dev/null @@ -1,8 +0,0 @@ -if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.warn( - 'Importing from `gamut-styles/utils/variables` is now deprecated, please import directly from the gamut-styles package' - ); -} - -export * from '../../dist/variables'; diff --git a/packages/gamut-tests/CHANGELOG.md b/packages/gamut-tests/CHANGELOG.md index 6bd61013352..92146696116 100644 --- a/packages/gamut-tests/CHANGELOG.md +++ b/packages/gamut-tests/CHANGELOG.md @@ -1,3 +1,19 @@ +## 6.0.1 (2026-05-14) + +### 🧱 Updated Dependencies + +- Updated gamut-styles to 18.0.0 + +# 6.0.0 (2026-05-04) + +### ⚠️ Breaking Changes + +- remove setupEnzyme and remove deprecated comment for RadialProgress ([#3337](https://github.com/Codecademy/gamut/pull/3337)) + +### ❤️ Thank You + +- Amy Resnik + ## 5.3.4 (2026-04-29) ### 🧱 Updated Dependencies diff --git a/packages/gamut-tests/package.json b/packages/gamut-tests/package.json index 64e2bc3d044..0e98b4ff2c7 100644 --- a/packages/gamut-tests/package.json +++ b/packages/gamut-tests/package.json @@ -1,10 +1,10 @@ { "name": "@codecademy/gamut-tests", "description": "Shared component test setup for Gamut applications", - "version": "5.3.4", + "version": "6.0.1", "author": "Codecademy Engineering ", "dependencies": { - "@codecademy/gamut-styles": "17.14.0", + "@codecademy/gamut-styles": "18.0.0", "component-test-setup": "^0.3.1", "lodash": "^4.17.23" }, diff --git a/packages/gamut-tests/src/index.tsx b/packages/gamut-tests/src/index.tsx index ff5d61022f6..8d9c73cadf2 100644 --- a/packages/gamut-tests/src/index.tsx +++ b/packages/gamut-tests/src/index.tsx @@ -1,8 +1,5 @@ import { GamutProvider, theme } from '@codecademy/gamut-styles'; -import { - setupEnzyme as setupEnzymeBase, - setupRtl as setupRtlBase, -} from 'component-test-setup'; +import { setupRtl as setupRtlBase } from 'component-test-setup'; import overArgs from 'lodash/overArgs'; import * as React from 'react'; @@ -38,14 +35,6 @@ function withMockGamutProvider( // overArgs isn't fully typed yet for lack of curried generics, so we have to cast it... -/** - * @deprecated Enzyme is no longer being maintained. Use RTL instead. - */ -export const setupEnzyme = overArgs( - setupEnzymeBase, - withMockGamutProvider -) as typeof setupEnzymeBase; - export const setupRtl = overArgs( setupRtlBase, withMockGamutProvider diff --git a/packages/gamut/CHANGELOG.md b/packages/gamut/CHANGELOG.md index 9073751143b..189e272c9c1 100644 --- a/packages/gamut/CHANGELOG.md +++ b/packages/gamut/CHANGELOG.md @@ -1,3 +1,78 @@ +## 68.6.1 (2026-05-20) + +### 🩹 Fixes + +- Update InternalFloatingCard, renamed to PatternBackdrop and pared down to needed functionality for Toast + ModalContainer needs ([#3353](https://github.com/Codecademy/gamut/pull/3353)) + +### ❤️ Thank You + +- Kenny Lin @LinKCoding +- LinKCoding @LinKCoding + +## 68.6.0 (2026-05-14) + +### 🚀 Features + +- Updates LXStudio theme to new guidelines ([#3341](https://github.com/Codecademy/gamut/pull/3341)) + +### 🧱 Updated Dependencies + +- Updated gamut-illustrations to 0.58.11 +- Updated gamut-patterns to 0.10.30 +- Updated gamut-styles to 18.0.0 +- Updated gamut-icons to 9.57.5 +- Updated gamut-tests to 6.0.1 + +### ❤️ Thank You + +- cassie spain @dreamwasp + +## 68.5.1 (2026-05-08) + +### 🩹 Fixes + +- pin formatjs/intl-locale version ([#3343](https://github.com/Codecademy/gamut/pull/3343)) + +### ❤️ Thank You + +- Amy Resnik + +## 68.5.0 (2026-05-04) + +### 🚀 Features + +- remove setupEnzyme and remove deprecated comment for RadialProgress ([#3337](https://github.com/Codecademy/gamut/pull/3337)) + +### 🧱 Updated Dependencies + +- Updated gamut-icons to 9.57.4 +- Updated gamut-tests to 6.0.0 + +### ❤️ Thank You + +- Amy Resnik + +## 68.4.1 (2026-05-04) + +### 🩹 Fixes + +- update Input padding to use logical properties ([#3338](https://github.com/Codecademy/gamut/pull/3338)) + +### ❤️ Thank You + +- Amy Resnik + +## 68.4.0 (2026-05-04) + +### 🚀 Features + +- New DatePicker component ([#3286](https://github.com/Codecademy/gamut/pull/3286), [#3335](https://github.com/Codecademy/gamut/issues/3335)) + +### ❤️ Thank You + +- Amy Resnik +- Kenny Lin @LinKCoding + ## 68.3.0 (2026-04-29) ### 🚀 Features diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 962e8667d61..7bdb1824d89 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -33,6 +33,10 @@ exports[`Gamut Exported Keys 1`] = ` "CTAButton", "DataList", "DataTable", + "DatePicker", + "DatePickerCalendar", + "DatePickerInput", + "DatePickerProvider", "DelayedRenderWrapper", "Dialog", "Disclosure", @@ -69,6 +73,7 @@ exports[`Gamut Exported Keys 1`] = ` "ListCol", "ListRow", "Markdown", + "matchDisabledDates", "Menu", "MenuItem", "MenuSeparator", @@ -113,6 +118,7 @@ exports[`Gamut Exported Keys 1`] = ` "ToolTip", "USE_DEBOUNCED_FIELD_DIRTY_KEY", "useConnectedForm", + "useDatePicker", "useDebouncedField", "useField", "useFormState", diff --git a/packages/gamut/jest.config.ts b/packages/gamut/jest.config.ts index 89cacf4d8c9..23d35b69523 100644 --- a/packages/gamut/jest.config.ts +++ b/packages/gamut/jest.config.ts @@ -8,5 +8,5 @@ export default base('gamut', { setupFiles: ['/../../script/jest/base-setup.js'], setupFilesAfterEnv: ['/../../script/jest/rtl-setup.js'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - transformIgnorePatterns: ['node_modules/(?!(@vidstack/react)/)'], + transformIgnorePatterns: ['node_modules/(?!(@vidstack/react|@formatjs)/)'], }); diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 9ef3ad044a4..bee5bc13ad3 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -1,14 +1,15 @@ { "name": "@codecademy/gamut", "description": "Styleguide & Component library for Codecademy", - "version": "68.3.0", + "version": "68.6.1", "author": "Codecademy Engineering ", "dependencies": { - "@codecademy/gamut-icons": "9.57.3", - "@codecademy/gamut-illustrations": "0.58.10", - "@codecademy/gamut-patterns": "0.10.29", - "@codecademy/gamut-styles": "17.14.0", + "@codecademy/gamut-icons": "9.57.5", + "@codecademy/gamut-illustrations": "0.58.11", + "@codecademy/gamut-patterns": "0.10.30", + "@codecademy/gamut-styles": "18.0.0", "@codecademy/variance": "0.26.1", + "@formatjs/intl-locale": "5.3.1", "@react-aria/interactions": "3.25.0", "@types/marked": "^4.0.8", "@vidstack/react": "^1.12.12", diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..b334e4992df --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -0,0 +1,202 @@ +import { MiniArrowLeftIcon, MiniArrowRightIcon } from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { Box, FlexBox } from '../Box'; +import { PopoverContainer } from '../PopoverContainer'; +import { DatePickerCalendar } from './DatePickerCalendar'; +import { + getDefaultRangeQuickActions, + getDefaultSingleQuickActions, +} from './DatePickerCalendar/utils/quickActions'; +import { DatePickerProvider } from './DatePickerContext'; +import type { + DatePickerContextValue, + DatePickerRangeContextValue, +} from './DatePickerContext/types'; +import { DatePickerInput } from './DatePickerInput'; +import type { DatePickerProps } from './types'; +import { useResolvedLocale } from './utils/locale'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; + +export const DatePicker: React.FC = (props) => { + const { + locale, + disableDate, + children, + mode, + translations: translationsProp, + inputSize, + quickActions, + placement = 'inline', + } = props; + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [focusGridSignal, setFocusGridSignal] = useState(false); + const [gridFocusRequested, setGridFocusRequested] = useState(false); + const [activeRangePart, setActiveRangePart] = + useState(null); + const inputRef = useRef(null); + const dialogId = useId(); + const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; + const isRtl = useElementDir() === 'rtl'; + + const clearGridFocusRequest = useCallback(() => { + setGridFocusRequested(false); + }, []); + + const resolvedLocale = useResolvedLocale(locale); + + const openCalendar = useCallback(() => { + setIsCalendarOpen(true); + }, []); + + const focusCalendar = useCallback(() => { + setGridFocusRequested(true); + setFocusGridSignal((signal) => !signal); + }, []); + + const closeCalendar = useCallback(() => { + setIsCalendarOpen(false); + setActiveRangePart(null); + setGridFocusRequested(false); + const shell = inputRef.current; + const toFocus = + shell?.querySelector('[role="spinbutton"]') ?? shell; + toFocus?.focus(); + }, []); + + useEffect(() => { + if (!isCalendarOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + closeCalendar(); + }; + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [isCalendarOpen, closeCalendar]); + + const contextValue = useMemo(() => { + const translations = { + ...DEFAULT_DATE_PICKER_TRANSLATIONS, + ...translationsProp, + }; + const resolvedQuickActions = + quickActions ?? + (mode === 'range' + ? getDefaultRangeQuickActions(translations) + : getDefaultSingleQuickActions(resolvedLocale)); + const base = { + isCalendarOpen, + openCalendar, + focusCalendar, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + closeCalendar, + locale: resolvedLocale, + disableDate, + translations, + quickActions: quickActions === null ? [] : resolvedQuickActions, + }; + return mode === 'range' + ? { + ...base, + mode: 'range', + startDate: props.startDate, + endDate: props.endDate, + activeRangePart, + setActiveRangePart, + onRangeSelection: (startDate: Date | null, endDate: Date | null) => { + props.onStartSelected(startDate); + props.onEndSelected(endDate); + }, + } + : { + ...base, + mode: 'single', + selectedDate: props.selectedDate, + onSelection: props.onSelected, + }; + }, [ + translationsProp, + quickActions, + mode, + resolvedLocale, + isCalendarOpen, + openCalendar, + focusCalendar, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + closeCalendar, + disableDate, + props, + activeRangePart, + ]); + + const content = + children !== undefined ? ( + children + ) : ( + <> + + {mode === 'range' ? ( + <> + + + {isRtl ? : } + + + + ) : ( + + )} + + + + + + ); + + return ( + {content} + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx new file mode 100644 index 00000000000..8ef8181ad6e --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -0,0 +1,245 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import * as React from 'react'; + +import { useIsoFirstWeekday, useResolvedLocale } from '../../utils/locale'; +import { CalendarBodyProps } from './types'; +import { + getDatesWithRow, + getMonthGrid, + isDateDisabled, + isDateInRange, + isSameDay, + normalizeDate, +} from './utils/dateGrid'; +import { CalendarTable, DateCell, TableHeader } from './utils/elements'; +import { formatDateForAriaLabel, getWeekdayNames } from './utils/format'; +import { keyHandler } from './utils/keyHandler'; + +export const CalendarBody: React.FC = ({ + displayDate, + selectedDate, + endDate = null, + disableDate, + onDateSelect, + locale, + weekStartsOn, + labelledById, + focusedDate, + onFocusedDateChange, + onDisplayDateChange, + onEscapeKeyPress, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, + focusGridSync, + pauseGridRoving, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); + const year = displayDate.getFullYear(); + const month = displayDate.getMonth(); + const weeks = getMonthGrid({ year, month, firstWeekday }); + const weekdayLabels = getWeekdayNames({ + format: 'short', + locale: resolvedLocale, + firstWeekday, + }); + const weekdayFullNames = getWeekdayNames({ + format: 'long', + locale: resolvedLocale, + firstWeekday, + }); + const buttonRefs = useRef>(new Map()); + const tableRef = useRef(null); + + const datesWithRow = useMemo(() => getDatesWithRow(weeks), [weeks]); + const focusTarget = focusedDate ?? selectedDate; + + const isToday = useCallback( + (date: Date | null) => date !== null && isSameDay(date, new Date()), + [] + ); + + const focusButton = useCallback((date: Date | null): boolean => { + if (date === null) return false; + const key = normalizeDate(date); + const el = buttonRefs.current.get(key); + if (!el) return false; + el.focus(); + return true; + }, []); + + useLayoutEffect(() => { + if (focusTarget === null) return; + + if (!focusGridSync) { + focusButton(focusTarget); + return; + } + + const activeEl = document.activeElement; + const inThisGrid = tableRef.current?.contains(activeEl) ?? false; + const containerEl = focusGridSync.calendarContainerRef.current; + const focusInCalendarContainer = containerEl?.contains(activeEl) ?? false; + const requested = focusGridSync.gridFocusRequested; + const focusOnNavChevron = + activeEl instanceof Element && + activeEl.closest('[data-calendar-month-nav]') != null; + + if (!requested && (pauseGridRoving || focusOnNavChevron)) { + return; + } + + // Month navigation unmounts the active cell; focus often lands on , the dialog shell, + // or another non-grid node — not inside the container, so we must still sync. + const focusLostFromCellUnmount = + activeEl === document.body || + activeEl === document.documentElement || + (activeEl instanceof HTMLElement && + containerEl != null && + containerEl.contains(activeEl) === false && + activeEl.contains(containerEl)); + + // Sync DOM focus when: navigating inside this table; first focus from input (keyboard open); + // focus is in the multi-month strip (cross-grid arrows); or focus was lost after the grid updated. + // Do not pull focus from the input when the user opened with the mouse and never entered the surface. + const shouldSyncFocus = + inThisGrid || + requested || + focusInCalendarContainer || + (focusLostFromCellUnmount && containerEl != null); + + if (!shouldSyncFocus) return; + + const finish = (success: boolean) => { + if (success && requested) { + focusGridSync.onGridFocusRequestHandled(); + } + }; + + let success = focusButton(focusTarget); + if (success) { + finish(true); + return; + } + + // New cells may not have refs until after this layout pass (e.g. display month just changed). + if (shouldSyncFocus) { + requestAnimationFrame(() => { + success = focusButton(focusTarget); + if (success) finish(true); + }); + } + }, [focusTarget, focusButton, focusGridSync, year, month, pauseGridRoving]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent, date: Date) => + keyHandler({ + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, + }), + [ + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthLeft, + hasAdjacentMonthRight, + ] + ); + + const setButtonRef = useCallback((date: Date, el: HTMLElement | null) => { + const normalizedDateTime = normalizeDate(date); + if (el) buttonRefs.current.set(normalizedDateTime, el); + else buttonRefs.current.delete(normalizedDateTime); + }, []); + + return ( + + + + {weekdayLabels.map((label, i) => ( + + {label} + + ))} + + + + {weeks.map((week, rowIndex) => ( + + {week.map((date, colIndex) => { + if (date === null) { + return ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label -- this is a false positive + + ); + } + const selected = + isSameDay(date, selectedDate) || isSameDay(date, endDate); + const range = !!selectedDate && !!endDate; + const inRange = + range && + isDateInRange({ + date, + startDate: selectedDate, + endDate, + }); + const disabled = isDateDisabled({ date, disableDate }); + const today = isToday(date); + const isFocused = + focusTarget !== null && isSameDay(date, focusTarget); + const rovingTabIndex = pauseGridRoving ? -1 : isFocused ? 0 : -1; + + return ( + setButtonRef(date, el as HTMLElement | null)} + role="gridcell" + tabIndex={rovingTabIndex} + onClick={() => { + if (!disabled) onDateSelect(date); + }} + onFocus={() => onFocusedDateChange?.(date)} + onKeyDown={(e: React.KeyboardEvent) => onKeyDown(e, date)} + > + {date.getDate()} + + ); + })} + + ))} + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx new file mode 100644 index 00000000000..fc6591ae3fd --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +import { FlexBox } from '../../../Box'; +import { TextButton } from '../../../Button'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; +import { CalendarFooterProps } from './types'; + +export const CalendarFooter: React.FC = ({ + clearButton, + quickActions = [], +}) => { + if (quickActions.length === 0 && !clearButton) return null; + + const actions = quickActions.slice(0, 3); + + return ( + + {clearButton && ( + + clearButton.onClick?.()} + > + {clearButton.text ?? + DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText} + + + )} + + {actions.map((action, index) => ( + + {action.displayText} + + ))} + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx new file mode 100644 index 00000000000..9b7e03011b1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { FlexBox } from '../../../Box'; +import { Text } from '../../../Typography'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavLastMonth } from './CalendarNavLastMonth'; +import { CalendarNavNextMonth } from './CalendarNavNextMonth'; +import { CalendarHeaderProps } from './types'; +import { formatMonthYear } from './utils/format'; + +export const CalendarHeader: React.FC = ({ + displayDate, + locale, + headingId, + onDisplayDateChange, + hideLastNav, + hideNextNav, + onLastMonthClick, + onNextMonthClick, + interceptTabToGrid, + onTabIntoGrid, +}) => { + const resolvedLocale = useResolvedLocale(locale); + + return ( + + {!hideLastNav && ( + + )} + + + {formatMonthYear({ date: displayDate, locale: resolvedLocale })} + + + {!hideNextNav && ( + + )} + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx new file mode 100644 index 00000000000..026930ea63c --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx @@ -0,0 +1,59 @@ +import { + MiniChevronLeftIcon, + MiniChevronRightIcon, +} from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import * as React from 'react'; + +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavProps } from './types'; +import { getRelativeMonthLabels } from './utils/format'; + +export const CalendarNavLastMonth: React.FC = ({ + displayDate, + onDisplayDateChange, + onLastMonthClick, + onTabIntoGrid, + interceptTabToGrid, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { lastMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); + const isRtl = useElementDir(buttonRef) === 'rtl'; + + const handleClick = (e: React.MouseEvent) => { + const lastMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() - 1, + 1 + ); + onDisplayDateChange?.(lastMonth); + onLastMonthClick?.(); + if (e.detail > 0) { + buttonRef.current?.blur(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey && interceptTabToGrid && onTabIntoGrid) { + e.preventDefault(); + onTabIntoGrid(); + } + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx new file mode 100644 index 00000000000..8d69aa2ff21 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx @@ -0,0 +1,59 @@ +import { + MiniChevronLeftIcon, + MiniChevronRightIcon, +} from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import * as React from 'react'; + +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavProps } from './types'; +import { getRelativeMonthLabels } from './utils/format'; + +export const CalendarNavNextMonth: React.FC = ({ + displayDate, + onDisplayDateChange, + onNextMonthClick, + onTabIntoGrid, + interceptTabToGrid, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { nextMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); + const isRtl = useElementDir(buttonRef) === 'rtl'; + + const handleClick = (e: React.MouseEvent) => { + const nextMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() + 1, + 1 + ); + onDisplayDateChange?.(nextMonth); + onNextMonthClick?.(); + if (e.detail > 0) { + buttonRef.current?.blur(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey && interceptTabToGrid && onTabIntoGrid) { + e.preventDefault(); + onTabIntoGrid(); + } + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx new file mode 100644 index 00000000000..425348fa6dd --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -0,0 +1,20 @@ +import { CheckerDense } from '@codecademy/gamut-patterns'; +import * as React from 'react'; + +import { Box } from '../../../Box'; +import { WithChildrenProp } from '../../../utils'; + +export const CalendarWrapper: React.FC = ({ children }) => ( + + + + {children} + + +); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx new file mode 100644 index 00000000000..27dea15dbbb --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx @@ -0,0 +1,319 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; + +import { getIsoFirstDayFromLocale } from '../../../utils/locale'; +import { CalendarBody } from '../CalendarBody'; +import { getMonthGrid } from '../utils/dateGrid'; +import { formatDateForAriaLabel } from '../utils/format'; + +const displayDate = new Date(2024, 2, 1); +const focusedDate = new Date(2024, 2, 15); +const mockOnDateSelect = jest.fn(); +const mockOnFocusedDateChange = jest.fn(); +const mockOnDisplayDateChange = jest.fn(); +const mockOnGridFocusRequestHandled = jest.fn(); +const mockOnEscapeKeyPress = jest.fn(); + +const defaultCalendarContainerRef = createRef(); + +const renderView = setupRtl(CalendarBody, { + displayDate, + selectedDate: null, + focusedDate, + labelledById: 'cal-heading', + locale: 'en-US', + onDateSelect: mockOnDateSelect, + onFocusedDateChange: mockOnFocusedDateChange, + onDisplayDateChange: mockOnDisplayDateChange, + onEscapeKeyPress: mockOnEscapeKeyPress, + focusGridSync: { + gridFocusRequested: false, + signal: false, + onGridFocusRequestHandled: mockOnGridFocusRequestHandled, + calendarContainerRef: defaultCalendarContainerRef, + }, +}); + +describe('CalendarBody', () => { + it('renders a grid labelled by the heading id', () => { + const { view } = renderView(); + + const grid = view.getByRole('grid'); + expect(grid).toHaveAttribute('aria-labelledby', 'cal-heading'); + }); + + it('selects a day when a gridcell is clicked', async () => { + const { view } = renderView(); + + const day15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await userEvent.click(day15); + + expect(mockOnDateSelect).toHaveBeenCalledTimes(1); + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 15)); + }); + + it('fires onFocusedDateChange when a day receives focus', () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + + expect(mockOnFocusedDateChange).toHaveBeenCalledTimes(1); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('renders a DateCell with isToday true when the date is today', () => { + const today = new Date(); + const { view } = renderView({ + selectedDate: today, + displayDate: today, + focusedDate: today, + }); + + const todayCell = view.getByLabelText( + formatDateForAriaLabel({ + date: today, + locale: new Intl.Locale('en-US'), + }) + ); + expect(todayCell).toHaveAttribute('aria-current', 'date'); + }); + + it('renders a DateCell with isDisabled true when the date is disabled', () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); + expect(disabledCell).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select a day when a disabled gridcell is clicked', async () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); + await userEvent.click(disabledCell); + + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange when a disabled day cell receives focus', async () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); + day1.focus(); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 1)); + }); + + it('renders a DateCell with isSelected true when the date is selected', () => { + const { view } = renderView({ selectedDate: new Date(2024, 2, 15) }); + + const selected = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(selected).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isInRange true when the date is in the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const inRange = view.getByRole('gridcell', { name: /March 17, 2024/i }); + expect(inRange).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isRangeStart true when the date is the start of the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const rangeStart = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(rangeStart).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isRangeEnd true when the date is the end of the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const rangeEnd = view.getByRole('gridcell', { name: /March 20, 2024/i }); + expect(rangeEnd).toHaveAttribute('aria-selected', 'true'); + }); + + it('moves DOM focus to the focus target and calls onGridFocusRequestHandled when grid focus is requested', async () => { + const { view } = renderView({ + focusGridSync: { + gridFocusRequested: true, + signal: false, + onGridFocusRequestHandled: mockOnGridFocusRequestHandled, + calendarContainerRef: createRef(), + }, + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await waitFor(() => expect(march15).toHaveFocus()); + expect(mockOnGridFocusRequestHandled).toHaveBeenCalled(); + }); + + it('sets tabIndex 0 on the focus target day and -1 on other day cells', () => { + const { view } = renderView(); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + const march10 = view.getByRole('gridcell', { name: /March 10, 2024/i }); + expect(march15).toHaveAttribute('tabIndex', '0'); + expect(march10).toHaveAttribute('tabIndex', '-1'); + }); + + it('uses selectedDate as the roving focus target when focusedDate is null', () => { + const { view } = renderView({ + focusedDate: null, + selectedDate: new Date(2024, 2, 22), + }); + + const march22 = view.getByRole('gridcell', { name: /March 22, 2024/i }); + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(march22).toHaveAttribute('tabIndex', '0'); + expect(march15).toHaveAttribute('tabIndex', '-1'); + }); + + it('moves DOM focus to the focus target when focusGridSync is omitted (standalone)', async () => { + const { view } = renderView({ + focusGridSync: undefined, + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await waitFor(() => expect(march15).toHaveFocus()); + }); + + it('renders seven weekday column headers with scope and abbreviations', () => { + const { view } = renderView(); + + const headers = view.getAllByRole('columnheader'); + expect(headers).toHaveLength(7); + headers.forEach((th) => { + expect(th).toHaveAttribute('scope', 'col'); + expect(th).toHaveAttribute('abbr'); + }); + }); + + it('renders one gridcell per calendar slot (days plus leading and trailing padding)', () => { + const { view } = renderView(); + + const firstWeekday = getIsoFirstDayFromLocale(new Intl.Locale('en-US')); + const weeks = getMonthGrid({ + year: 2024, + month: 2, + firstWeekday, + }); + const expectedCellCount = weeks.flat().length; + + expect(view.getAllByRole('gridcell')).toHaveLength(expectedCellCount); + }); + + describe('keyboard navigation (integration)', () => { + it('moves focus via ArrowLeft through keyHandler', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{ArrowLeft}'); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 2, 19) + ); + }); + + it('updates focus and the visible month via PageDown through keyHandler', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{PageDown}'); + + expect(mockOnFocusedDateChange).toHaveBeenLastCalledWith( + new Date(2024, 3, 20) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 3, 1) + ); + }); + + it('selects the focused day on Enter', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{Enter}'); + + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('selects the focused day when key is Space', () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + fireEvent.keyDown(day20, { key: ' ' }); + + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('does not select a disabled day on Enter', async () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); + day1.focus(); + await userEvent.keyboard('{Enter}'); + + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onEscapeKeyPress on Escape', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{Escape}'); + + expect(mockOnEscapeKeyPress).toHaveBeenCalled(); + }); + + it('does not move focus after an unhandled key (beyond focus sync)', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 2, 20) + ); + mockOnFocusedDateChange.mockClear(); + + await userEvent.keyboard('{x}'); + + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx new file mode 100644 index 00000000000..fde752b22cb --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx @@ -0,0 +1,95 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; + +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../../utils/translations'; +import { CalendarFooter } from '../CalendarFooter'; + +const mockOnClearDate = jest.fn(); +const mockOnQuickActionClick = jest.fn(); + +const renderView = setupRtl(CalendarFooter, { + clearButton: { + onClick: mockOnClearDate, + }, + quickActions: [ + { + num: 0, + timePeriod: 'day', + displayText: 'Today', + onClick: mockOnQuickActionClick, + }, + ], +}); + +describe('CalendarFooter', () => { + it('renders null when there are no quick actions and no clear button', () => { + const { view } = renderView({ + clearButton: undefined, + quickActions: undefined, + }); + + expect(view.queryByRole('button')).toBeNull(); + }); + + describe('clear button', () => { + it('renders clear button when clearButton object is passed and calls onClick when clicked', async () => { + const { view } = renderView(); + + view.getByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }); + }); + + it('renders clear button with custom text when text is passed', () => { + const { view } = renderView({ + clearButton: { onClick: mockOnClearDate, text: 'Custom text' }, + }); + + view.getByRole('button', { name: 'Custom text' }); + }); + + it('calls onClick when clear button is clicked', async () => { + const { view } = renderView(); + + await userEvent.click( + view.getByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }) + ); + + expect(mockOnClearDate).toHaveBeenCalledTimes(1); + }); + + it('does not render clear button when clearButton object is not passed', () => { + const { view } = renderView({ clearButton: undefined }); + + expect( + view.queryByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }) + ).toBeNull(); + }); + }); + + describe('quick actions', () => { + it('renders quick actions when quickActions array is passed', () => { + const { view } = renderView(); + + view.getByRole('button', { name: 'Today' }); + }); + + it('does not render quick actions when quickActions array is not passed', () => { + const { view } = renderView({ quickActions: undefined }); + + expect(view.queryByRole('button', { name: 'Today' })).toBeNull(); + }); + + it('calls onClick when a quick action is clicked', async () => { + const { view } = renderView(); + + await userEvent.click(view.getByRole('button', { name: 'Today' })); + + expect(mockOnQuickActionClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx new file mode 100644 index 00000000000..c606e262759 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx @@ -0,0 +1,48 @@ +import { setupRtl } from '@codecademy/gamut-tests'; + +import { CalendarHeader } from '../CalendarHeader'; + +const displayDate = new Date(2024, 2, 1); +const mockOnDisplayDateChange = jest.fn(); +const mockOnLastMonthClick = jest.fn(); +const mockOnNextMonthClick = jest.fn(); + +const renderView = setupRtl(CalendarHeader, { + displayDate, + headingId: 'cal-heading', + locale: 'en-US', + onDisplayDateChange: mockOnDisplayDateChange, + onLastMonthClick: mockOnLastMonthClick, + onNextMonthClick: mockOnNextMonthClick, +}); + +describe('CalendarHeader', () => { + it('renders the month/year title with the given id', () => { + const { view } = renderView(); + + const title = view.container.querySelector('#cal-heading'); + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent('March 2024'); + }); + + it('renders last month and next month nav buttons when hideLastNav and hideNextNav are false', () => { + const { view } = renderView(); + + view.getByRole('button', { name: 'Last month' }); + view.getByRole('button', { name: 'Next month' }); + }); + + it('hides last nav when hideLastNav is true', () => { + const { view } = renderView({ hideLastNav: true }); + + expect(view.queryByRole('button', { name: 'Last month' })).toBeNull(); + view.getByRole('button', { name: 'Next month' }); + }); + + it('hides next nav when hideNextNav is true', () => { + const { view } = renderView({ hideNextNav: true }); + + expect(view.queryByRole('button', { name: 'Next month' })).toBeNull(); + view.queryByRole('button', { name: 'Last month' }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx new file mode 100644 index 00000000000..ec2eeea83d5 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx @@ -0,0 +1,47 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; + +import { CalendarNavLastMonth } from '../CalendarNavLastMonth'; +import { CalendarNavNextMonth } from '../CalendarNavNextMonth'; + +const onLastDisplayChange = jest.fn(); +const mockOnLastMonthClick = jest.fn(); +const renderLast = setupRtl(CalendarNavLastMonth, { + displayDate: new Date(2024, 5, 15), + locale: 'en-US', + onDisplayDateChange: onLastDisplayChange, + onLastMonthClick: mockOnLastMonthClick, +}); + +const onNextDisplayChange = jest.fn(); +const mockOnNextMonthClick = jest.fn(); +const renderNext = setupRtl(CalendarNavNextMonth, { + displayDate: new Date(2024, 5, 15), + locale: 'en-US', + onDisplayDateChange: onNextDisplayChange, + onNextMonthClick: mockOnNextMonthClick, +}); + +describe('CalendarNavLastMonth', () => { + it('moves displayDate to the first day of the previous month', async () => { + const { view } = renderLast(); + + await userEvent.click(view.getByRole('button')); + + expect(onLastDisplayChange).toHaveBeenCalledTimes(1); + expect(onLastDisplayChange).toHaveBeenCalledWith(new Date(2024, 4, 1)); + expect(mockOnLastMonthClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('CalendarNavNextMonth', () => { + it('moves displayDate to the first day of the next month', async () => { + const { view } = renderNext(); + + await userEvent.click(view.getByRole('button')); + + expect(onNextDisplayChange).toHaveBeenCalledTimes(1); + expect(onNextDisplayChange).toHaveBeenCalledWith(new Date(2024, 6, 1)); + expect(mockOnNextMonthClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx new file mode 100644 index 00000000000..e1fcffcbdc0 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx @@ -0,0 +1,7 @@ +export * from './CalendarWrapper'; +export * from './CalendarHeader'; +export * from './CalendarBody'; +export * from './CalendarFooter'; +export * from './CalendarNavLastMonth'; +export * from './CalendarNavNextMonth'; +export * from './types'; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts new file mode 100644 index 00000000000..771ce5f5b1f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -0,0 +1,153 @@ +import type { RefObject } from 'react'; + +import type { + CalendarQuickAction, + DatePickerSharedProps, +} from '../../sharedTypes'; +import type { IsoWeekday } from '../../utils/locale'; + +interface CalendarBaseProps extends DatePickerSharedProps { + /** + * Date used to display month and year in the calendar header. + */ + displayDate: Date; + /** + * Callback when the visible month changes. Pass the new `Date` to your `displayDate` state (or + * equivalent) so the calendar stays in sync. + */ + onDisplayDateChange: (newDate: Date) => void; +} + +export interface CalendarNavProps + extends Omit { + /** Callback called after the user navigates to the previous month. */ + onLastMonthClick?: () => void; + /** Callback called after the user navigates to the next month. */ + onNextMonthClick?: () => void; + /** + * Whether to intercept focus from the month chevron buttons. Used when the day grid is temporarily "paused" after a + * month change until the user moves into the grid. + * + */ + interceptTabToGrid?: boolean; + /** + * Callback called when {@link interceptTabToGrid} is set to move focus into the day grid + * and restore roving focus. + */ + onTabIntoGrid?: () => void; +} + +export interface CalendarHeaderProps extends CalendarNavProps { + /** Hides the control that moves to the previous month. */ + hideLastNav?: boolean; + /** Hides the control that moves to the next month. */ + hideNextNav?: boolean; + /** + * Date used to display month and year in the second month's calendar header. + */ + secondDisplayDate?: Date; + /** + * `id` of the visible month heading, used for the grid `aria-labelledby` association. + */ + headingId: string; +} + +export interface CalendarBodyProps extends CalendarBaseProps { + /** + * Start of the selected range, or the single selected date. Pass `null` when nothing is + * selected. + */ + selectedDate: Date | null; + /** + * End of the range. Omit in single-date mode, or pass `null` for no end date in range mode. + */ + endDate?: Date | null; + /** + * Callback when the user chooses a day cell + */ + onDateSelect: (date: Date) => void; + /** + * First column of the grid as an ISO weekday (`1` = Monday through `7` = Sunday), matching + * `Intl.Locale.prototype.getWeekInfo().firstDay`. Omit to use the active locale. + */ + weekStartsOn?: IsoWeekday; + /** + * `id` of the month heading, used for the grid `aria-labelledby` association. + */ + labelledById: string; + /** + * Which day should hold roving `tabindex` in the grid. + */ + focusedDate: Date | null; + /** + * Callback when the focused day changes, including from arrow keys, click, and programmatic + * updates to `focusedDate`. + */ + onFocusedDateChange: (date: Date | null) => void; + /** + * Callback when the user presses Escape while a day is focused. The + * `DatePicker` shell uses this to close the calendar popover. + */ + onEscapeKeyPress?: () => void; + /** + * Whether the current month has a second grid to the right. + */ + hasAdjacentMonthRight?: boolean; + /** + * Whether the current month has a second grid to the left. + */ + hasAdjacentMonthLeft?: boolean; + /** + * Focus management contract for the `DatePicker` shell. Programmatically moves DOM + * focus to a day only when this grid, or a wider `calendarContainerRef` region, already has + * focus, or when `gridFocusRequested` is set (e.g. keyboard open or ArrowDown from + * the field). + * + */ + focusGridSync?: { + /** + * Whether the shell requested a one-shot move of focus into the grid (e.g. from the + * input or trigger). + */ + gridFocusRequested: boolean; + /** + * Whether a grid-focus request is issued with an unchanged `focusedDate`, so layout + * effects that depend on focus can still re-run. + */ + signal: boolean; + /** + * Call after DOM focus was successfully moved into the grid in response to + * `gridFocusRequested` so the shell can clear the request. + */ + onGridFocusRequestHandled: () => void; + /** + * Ref to an element that wraps the calendar (e.g. the two month tables). + */ + calendarContainerRef: RefObject; + }; + /** + * Whether to pause grid roving, i.e. should all day cells use `tabIndex={-1}` until the user moves into the grid + * so the grid does not steal focus during month transitions. + */ + pauseGridRoving?: boolean; +} + +export interface CalendarFooterProps { + /** + * "Clear" action in the footer. + */ + clearButton?: { + /** Whether the clear button is disabled. */ + disabled?: boolean; + /** Callback called when the clear button is clicked. */ + onClick?: () => void; + /** Text to display for the clear button. `DatePickerCalendar` uses `translations.clearButtonText` */ + text?: string; + }; + /** + * Shortcut actions. See {@link CalendarQuickAction} for the + * object shape. The `DatePicker` shell provides defaults for single and range mode unless you + * override. + */ + quickActions?: CalendarQuickAction[]; +} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts new file mode 100644 index 00000000000..023667dc1f0 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -0,0 +1,167 @@ +import { + getDatesWithRow, + getMonthGrid, + getWeekdayOffsetInGrid, + isDateDisabled, + isDateInRange, + isDateWithinVisibleMonths, + isSameDay, + matchDisabledDates, +} from '../dateGrid'; + +describe('getWeekdayOffsetInGrid', () => { + it('returns 0 when the 1st matches the grid first weekday (Monday)', () => { + const first = new Date(2024, 0, 1); + expect(getWeekdayOffsetInGrid({ date: first, firstWeekday: 1 })).toBe(0); + }); + + it('returns a positive offset when the 1st is later in the week than firstWeekday', () => { + const first = new Date(2024, 0, 1); + expect( + getWeekdayOffsetInGrid({ date: first, firstWeekday: 7 }) + ).toBeGreaterThan(0); + }); +}); + +describe('getMonthGrid', () => { + it('includes exactly the number of days in the month', () => { + const weeks = getMonthGrid({ year: 2024, month: 2, firstWeekday: 1 }); + const days = weeks.flat().filter((d): d is Date => d !== null); + expect(days).toHaveLength(31); + }); + + it('pads leading and trailing cells with null so each row has 7 cells', () => { + const weeks = getMonthGrid({ year: 2024, month: 2, firstWeekday: 1 }); + weeks.forEach((row) => { + expect(row).toHaveLength(7); + }); + }); +}); + +describe('isSameDay', () => { + it('returns true for the same calendar day in local time', () => { + const a = new Date(2024, 5, 15, 8, 0); + const b = new Date(2024, 5, 15, 22, 0); + expect(isSameDay(a, b)).toBe(true); + }); + + it('returns false for different days or null', () => { + expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 5, 16))).toBe(false); + expect(isSameDay(new Date(), null)).toBe(false); + }); +}); + +describe('isDateInRange', () => { + const startDate = new Date(2024, 2, 10); + const endDate = new Date(2024, 2, 20); + + it('returns true strictly between startDate and endDate', () => { + expect( + isDateInRange({ date: new Date(2024, 2, 15), startDate, endDate }) + ).toBe(true); + }); + + it('returns false on startDate, endDate, or outside', () => { + expect(isDateInRange({ date: startDate, startDate, endDate })).toBe(false); + expect(isDateInRange({ date: endDate, startDate, endDate })).toBe(false); + expect( + isDateInRange({ date: new Date(2024, 2, 5), startDate, endDate }) + ).toBe(false); + }); + + it('returns false when startDate is null', () => { + expect( + isDateInRange({ date: new Date(2024, 2, 15), startDate: null, endDate }) + ).toBe(false); + }); +}); + +describe('matchDisabledDates', () => { + it('returns true when any listed day matches the calendar day', () => { + const target = new Date(2024, 4, 10); + const shouldDisable = matchDisabledDates([new Date(2024, 4, 10, 15, 30)]); + expect(shouldDisable(target)).toBe(true); + }); + + it('returns false when the list is empty or no day matches', () => { + expect(matchDisabledDates([])(new Date(2024, 4, 10))).toBe(false); + expect( + matchDisabledDates([new Date(2024, 4, 11)])(new Date(2024, 4, 10)) + ).toBe(false); + }); +}); + +describe('isDateDisabled', () => { + it('returns true when disableDate returns true', () => { + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: () => true, + }) + ).toBe(true); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: (d) => d.getDate() === 10, + }) + ).toBe(true); + }); + + it('returns false when disableDate is omitted or returns false', () => { + expect(isDateDisabled({ date: new Date(2024, 4, 10) })).toBe(false); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: () => false, + }) + ).toBe(false); + }); +}); + +describe('isDateWithinVisibleMonths', () => { + const march2024 = new Date(2024, 2, 1); + const april2024 = new Date(2024, 3, 15); + + it('returns true when the date is in the left visible month', () => { + expect( + isDateWithinVisibleMonths({ + date: new Date(2024, 2, 20), + startOfLeftVisibleMonth: march2024, + showSecondMonth: false, + }) + ).toBe(true); + }); + + it('returns true when the date is in the second column month in a two-month layout', () => { + expect( + isDateWithinVisibleMonths({ + date: april2024, + startOfLeftVisibleMonth: march2024, + showSecondMonth: true, + }) + ).toBe(true); + }); + + it('returns false when the date is outside the visible month(s)', () => { + expect( + isDateWithinVisibleMonths({ + date: new Date(2024, 4, 1), + startOfLeftVisibleMonth: march2024, + showSecondMonth: true, + }) + ).toBe(false); + }); +}); + +describe('getDatesWithRow', () => { + it('lists only non-null dates with row indices', () => { + const weeks = getMonthGrid({ year: 2024, month: 0, firstWeekday: 1 }); + const withRow = getDatesWithRow(weeks); + expect(withRow.length).toBe(31); + expect(withRow[0].rowIndex).toBe(0); + withRow.forEach(({ date }) => { + expect(date.getMonth()).toBe(0); + expect(date.getFullYear()).toBe(2024); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts new file mode 100644 index 00000000000..3fe4353b50a --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts @@ -0,0 +1,142 @@ +import { + capitalizeFirst, + formatDateForAriaLabel, + formatMonthYear, + getRelativeMonthLabels, + getRelativeTodayLabel, + getWeekdayNames, +} from '../format'; + +const enUS = new Intl.Locale('en-US'); +const frFR = new Intl.Locale('fr-FR'); + +describe('capitalizeFirst', () => { + it('uppercases the first character per locale', () => { + expect(capitalizeFirst({ str: 'hello', locale: enUS })).toBe('Hello'); + }); + + it('returns empty string unchanged', () => { + expect(capitalizeFirst({ str: '', locale: enUS })).toBe(''); + }); +}); + +describe('formatMonthYear', () => { + it('formats month in long format and year in numeric format', () => { + const text = formatMonthYear({ date: new Date(2026, 0, 15), locale: enUS }); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('january'); + }); + + it('formats month and year based on the given locale', () => { + const text = formatMonthYear({ date: new Date(2026, 0, 15), locale: frFR }); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('janvier'); + }); +}); + +describe('getWeekdayNames', () => { + it('returns short weekday names when format is short', () => { + const short = getWeekdayNames({ + format: 'short', + locale: enUS, + firstWeekday: 1, + }); + expect(short).toHaveLength(7); + expect(short).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('returns long weekday names when format is long', () => { + const long = getWeekdayNames({ + format: 'long', + locale: enUS, + firstWeekday: 7, + }); + expect(long).toHaveLength(7); + expect(long).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]); + }); + + it('returns the correct weekday name order for the given firstWeekday', () => { + const short = getWeekdayNames({ + format: 'short', + locale: frFR, + firstWeekday: 1, + }); + expect(short).toEqual([ + 'lun.', + 'mar.', + 'mer.', + 'jeu.', + 'ven.', + 'sam.', + 'dim.', + ]); + const long = getWeekdayNames({ + format: 'long', + locale: frFR, + firstWeekday: 7, + }); + expect(long).toEqual([ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ]); + }); +}); + +describe('getRelativeMonthLabels', () => { + it('returns next and last month strings in the given locale (en-US)', () => { + const { nextMonth, lastMonth } = getRelativeMonthLabels(enUS); + expect(nextMonth).toEqual('Next month'); + expect(lastMonth).toEqual('Last month'); + }); + + it('returns next and last month strings in the given locale (fr-FR)', () => { + const { nextMonth, lastMonth } = getRelativeMonthLabels(frFR); + expect(nextMonth).toEqual('Le mois prochain'); + expect(lastMonth).toEqual('Le mois dernier'); + }); +}); + +describe('getRelativeTodayLabel', () => { + it('returns today string in the given locale (en-US)', () => { + expect(getRelativeTodayLabel(enUS)).toEqual('Today'); + }); + + it('returns today string in the given locale (fr-FR)', () => { + expect(getRelativeTodayLabel(frFR)).toEqual('Aujourd’hui'); + }); +}); + +describe('formatDateForAriaLabel', () => { + it('formats month in long format, day in numeric format, and year in numeric format in the given locale (en-US)', () => { + const label = formatDateForAriaLabel({ + date: new Date(2026, 1, 14), + locale: enUS, + }); + expect(label).toMatch(/2026/); + expect(label.toLowerCase()).toContain('february'); + expect(label).toMatch(/14/); + }); + + it('formats month in long format, day in numeric format, and year in numeric format in the given locale (fr-FR)', () => { + const label = formatDateForAriaLabel({ + date: new Date(2026, 1, 14), + locale: frFR, + }); + expect(label).toMatch(/2026/); + expect(label.toLowerCase()).toContain('février'); + expect(label).toMatch(/14/); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx new file mode 100644 index 00000000000..a404686ec54 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx @@ -0,0 +1,410 @@ +import type { KeyboardEvent } from 'react'; + +import { getDatesWithRow, getMonthGrid, matchDisabledDates } from '../dateGrid'; +import { keyHandler, KeyHandlerParams } from '../keyHandler'; + +const makeEvent = ( + key: string, + opts: Partial> = {} +) => + ({ + key, + preventDefault: jest.fn(), + shiftKey: opts.shiftKey ?? false, + } as unknown as React.KeyboardEvent); + +const mockOnFocusedDateChange = jest.fn(); +const mockOnDateSelect = jest.fn(); +const mockOnEscapeKeyPress = jest.fn(); +const mockOnDisplayDateChange = jest.fn(); + +const year = 2024; +const month = 2; +const firstWeekday = 1 as const; +const weeks = getMonthGrid({ year, month, firstWeekday }); +const datesWithRow = getDatesWithRow(weeks); +const midIdx = Math.floor(datesWithRow.length / 2); +const { date } = datesWithRow[midIdx]; +const firstIdx = 0; +const lastIdx = datesWithRow.length - 1; + +const baseParams: Omit = { + date, + datesWithRow, + month, + year, + hasAdjacentMonthRight: false, + hasAdjacentMonthLeft: false, + onDisplayDateChange: mockOnDisplayDateChange, + onFocusedDateChange: mockOnFocusedDateChange, + onDateSelect: mockOnDateSelect, + onEscapeKeyPress: mockOnEscapeKeyPress, +}; + +describe('keyHandler', () => { + it('calls onFocusedDateChange for ArrowLeft to previous day in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx - 1].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowRight to next day in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowRight'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx + 1].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowUp to previous week in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowUp'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx - 7].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowDown to next week in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowDown'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx + 7].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('moves focus to the last day of the previous month when ArrowLeft is pressed on the first day of the month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month, 0) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month - 1, 1) + ); + }); + + it('moves focus to the first day of the next month when ArrowRight is pressed on the last day of the month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowRight'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + }); + + it('moves focus up a week and updates the month view when ArrowUp crosses into the previous month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowUp'), + }); + const expected = new Date(datesWithRow[firstIdx].date); + expected.setDate(expected.getDate() - 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(expected.getFullYear(), expected.getMonth(), 1) + ); + }); + + it('moves focus down a week and updates the month view when ArrowDown crosses into the next month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowDown'), + }); + const expected = new Date(datesWithRow[lastIdx].date); + expected.setDate(expected.getDate() + 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(expected.getFullYear(), expected.getMonth(), 1) + ); + }); + + it('calls onDateSelect on Enter when not disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Enter'), + }); + expect(mockOnDateSelect).toHaveBeenCalledWith(date); + }); + + it('does not call onDateSelect on Enter when date is disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Enter'), + disableDate: matchDisabledDates([date]), + }); + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onEscapeKeyPress on Escape', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Escape'), + }); + expect(mockOnEscapeKeyPress).toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on PageDown for next month', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageDown'), + }); + const expectedFocus = new Date(year, month + 1, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on Shift+PageDown for next year', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageDown', { shiftKey: true }), + }); + const expectedFocus = new Date(year + 1, month, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year + 1, month, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on PageUp for previous month', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageUp'), + }); + const expectedFocus = new Date(year, month - 1, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month - 1, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on Shift+PageUp for previous year', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageUp', { shiftKey: true }), + }); + const expectedFocus = new Date(year - 1, month, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year - 1, month, 1) + ); + }); + + it('moves focus to the first day of the current week row when Home key is pressed', () => { + const { rowIndex } = datesWithRow[midIdx]; + const firstInRow = datesWithRow.find((d) => d.rowIndex === rowIndex)!.date; + + keyHandler({ + ...baseParams, + e: makeEvent('Home'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(firstInRow); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('moves focus to the last day of the current week row when End key is pressed', () => { + const { rowIndex } = datesWithRow[midIdx]; + const lastInRow = [...datesWithRow] + .reverse() + .find((d) => d.rowIndex === rowIndex)!.date; + + keyHandler({ + ...baseParams, + e: makeEvent('End'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(lastInRow); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does nothing for unhandled keys', () => { + keyHandler({ + ...baseParams, + e: makeEvent('x'), + }); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + describe('edge cases', () => { + it('returns early when the active date is not in datesWithRow', () => { + keyHandler({ + ...baseParams, + date: new Date(2024, 2, 99), + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowLeft leaves the month but a second month is visible on the left', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowLeft'), + hasAdjacentMonthLeft: true, + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month, 0) + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowRight leaves the month but a second month is visible on the right', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowRight'), + hasAdjacentMonthRight: true, + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowUp crosses months but a second month is visible on the left', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowUp'), + hasAdjacentMonthLeft: true, + }); + const expected = new Date(datesWithRow[firstIdx].date); + expected.setDate(expected.getDate() - 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowDown crosses months but a second month is visible on the right', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowDown'), + hasAdjacentMonthRight: true, + }); + const expected = new Date(datesWithRow[lastIdx].date); + expected.setDate(expected.getDate() + 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('clamps PageDown from Jan 31 to Feb 29 in a leap year', () => { + const janWeeks = getMonthGrid({ year: 2024, month: 0, firstWeekday }); + const janDates = getDatesWithRow(janWeeks); + const jan31 = janDates.find((d) => d.date.getDate() === 31)!.date; + + keyHandler({ + ...baseParams, + date: jan31, + datesWithRow: janDates, + month: 0, + year: 2024, + e: makeEvent('PageDown'), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 29) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 1) + ); + }); + + it('clamps PageUp from Mar 31 to Feb 29 in a leap year', () => { + const marWeeks = getMonthGrid({ year: 2024, month: 2, firstWeekday }); + const marDates = getDatesWithRow(marWeeks); + const mar31 = marDates.find((d) => d.date.getDate() === 31)!.date; + + keyHandler({ + ...baseParams, + date: mar31, + datesWithRow: marDates, + month: 2, + year: 2024, + e: makeEvent('PageUp'), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 29) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 1) + ); + }); + + it('clamps Shift+PageUp from Feb 29 to Feb 28 when the previous year is not a leap year', () => { + const febWeeksLeap = getMonthGrid({ year: 2024, month: 1, firstWeekday }); + const febDates = getDatesWithRow(febWeeksLeap); + const feb29 = febDates.find((d) => d.date.getDate() === 29)!.date; + + keyHandler({ + ...baseParams, + date: feb29, + datesWithRow: febDates, + month: 1, + year: 2024, + e: makeEvent('PageUp', { shiftKey: true }), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2023, 1, 28) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2023, 1, 1) + ); + }); + + it('calls onDateSelect on Space when not disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent(' '), + }); + expect(mockOnDateSelect).toHaveBeenCalledWith(date); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDateSelect on Space when the date is disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent(' '), + disableDate: matchDisabledDates([date]), + }); + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('does not throw when Escape is pressed and onEscapeKeyPress is omitted', () => { + expect(() => + keyHandler({ + ...baseParams, + onEscapeKeyPress: undefined, + e: makeEvent('Escape'), + }) + ).not.toThrow(); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts new file mode 100644 index 00000000000..7d6e0917c6b --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -0,0 +1,195 @@ +import type { IsoWeekday } from '../../../utils/locale'; + +const DAYS_PER_WEEK = 7; + +export const normalizeDate = (date: Date) => { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ).getTime(); +}; + +/** + * Number of empty cells before the 1st of the month, for a grid whose first column is + * `firstWeekday` (ISO: 1 = Monday … 7 = Sunday from `Intl.Locale#getWeekInfo`). + */ +export const getWeekdayOffsetInGrid = ({ + date, + firstWeekday, +}: { + date: Date; + firstWeekday: IsoWeekday; +}) => { + const dayOfWeek = date.getDay(); + const iso = dayOfWeek === 0 ? 7 : dayOfWeek; + return (iso - firstWeekday + 14) % 7; +}; + +export const getFirstOfMonth = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), 1); +}; + +/** + * Returns an array of weeks for the given month. Each week is an array of 7 items: + * each item is either a Date (that day) or null (padding from previous/next month). + * + * @param year - Full year (e.g. 2026) + * @param month - Month 0-11 (0 = January) + * @param firstWeekday - First day of the week for the calendar row (ISO 1–7, from `getWeekInfo().firstDay`) + */ +export const getMonthGrid = ({ + year, + month, + firstWeekday, +}: { + year: number; + month: number; + firstWeekday: IsoWeekday; +}) => { + const first = getFirstOfMonth(new Date(year, month, 1)); + const last = new Date(year, month + 1, 0); + const firstDayOfWeek = getWeekdayOffsetInGrid({ date: first, firstWeekday }); + const daysInMonth = last.getDate(); + + const weeks: (Date | null)[][] = []; + let currentWeek: (Date | null)[] = []; + + for (let i = 0; i < firstDayOfWeek; i += 1) { + currentWeek.push(null); + } + + for (let day = 1; day <= daysInMonth; day += 1) { + currentWeek.push(new Date(year, month, day)); + if (currentWeek.length === DAYS_PER_WEEK) { + weeks.push(currentWeek); + currentWeek = []; + } + } + + if (currentWeek.length > 0) { + while (currentWeek.length < DAYS_PER_WEEK) { + currentWeek.push(null); + } + weeks.push(currentWeek); + } + + return weeks; +}; + +export const isSameDay = (a: Date | null, b: Date | null) => { + if (a === null || b === null) return false; + return normalizeDate(a) === normalizeDate(b); +}; + +export const getOrderedCalendarEndpoints = ({ + startDate, + endDate, +}: { + startDate: Date; + endDate: Date; +}) => { + const normalizedStartDate = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + ); + const normalizedEndDate = new Date( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate() + ); + return normalizedStartDate <= normalizedEndDate + ? { low: normalizedStartDate, high: normalizedEndDate } + : { low: normalizedEndDate, high: normalizedStartDate }; +}; + +/** + * Check if `date` is between `startDate` and `endDate` (exclusive), ignoring time. + */ +export const isDateInRange = ({ + date, + startDate, + endDate, +}: { + date: Date; + startDate: Date | null; + endDate: Date | null; +}) => { + if (startDate === null) return false; + const endBound = endDate ?? startDate; + const { low, high } = getOrderedCalendarEndpoints({ + startDate, + endDate: endBound, + }); + const normalizedDate = normalizeDate(date); + return ( + normalizedDate > normalizeDate(low) && normalizedDate < normalizeDate(high) + ); +}; + +/** + * Returns a `disableDate` callback: for each calendar `date`, `true` if it’s the same day as + * any in `dates` + * + * @example + * ```tsx + * {}} + * disableDate={matchDisabledDates([new Date(2026, 3, 14)])} + * /> + * ``` + */ +export const matchDisabledDates = + (dates: readonly Date[] = []) => + (date: Date): boolean => + dates.some((d) => isSameDay(date, d)); + +export const isDateDisabled = ({ + date, + disableDate, +}: { + date: Date; + disableDate?: (date: Date) => boolean; +}) => Boolean(disableDate?.(date)); + +export type DateWithRow = { date: Date; rowIndex: number }; + +export const getDatesWithRow = (weeks: (Date | null)[][]) => { + const result: DateWithRow[] = []; + weeks.forEach((week, rowIndex) => { + week.forEach((date) => { + if (date !== null) result.push({ date, rowIndex }); + }); + }); + return result; +}; + +export const addMonths = ({ date, n }: { date: Date; n: number }) => + new Date(date.getFullYear(), date.getMonth() + n, 1); + +export const isDateWithinVisibleMonths = ({ + date, + startOfLeftVisibleMonth, + showSecondMonth, +}: { + date: Date; + /** First day of the month rendered in the left calendar column (`displayDate`). */ + startOfLeftVisibleMonth: Date; + showSecondMonth: boolean; +}) => { + const year = date.getFullYear(); + const month = date.getMonth(); + const leftYear = startOfLeftVisibleMonth.getFullYear(); + const leftMonth = startOfLeftVisibleMonth.getMonth(); + if (year === leftYear && month === leftMonth) return true; + if (showSecondMonth) { + const rightMonthStart = addMonths({ date: startOfLeftVisibleMonth, n: 1 }); + const rightYear = rightMonthStart.getFullYear(); + const rightMonth = rightMonthStart.getMonth(); + return year === rightYear && month === rightMonth; + } + return false; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx new file mode 100644 index 00000000000..b5a7354e8d7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -0,0 +1,129 @@ +import { css, states, transitionConcat } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +export const CalendarTable = styled.table( + css({ + /** Row gaps only: 0 between columns, 8px token between rows (incl. under header). */ + borderCollapse: 'separate', + borderSpacing: '0 8px', + }) +); + +export const TableHeader = styled.th( + css({ + fontSize: 14, + fontWeight: 'base', + color: 'text-disabled', + textAlign: 'center', + }) +); + +const datecellStates = states({ + isToday: { + position: 'relative', + '&::after': { + content: '""', + position: 'absolute', + bottom: 4, + /** Half of dot width (4px) so the marker sits under the centered date numeral. */ + insetInlineStart: 'calc(50% - 2px)', + width: 4, + height: 4, + borderRadius: 'full', + bg: 'hyper', + }, + }, + isSelected: { + bg: 'text', + color: 'background', + '&:hover, &:focus': { + bg: 'secondary-hover', + color: 'background', + }, + '&::after': { + bg: 'background', + }, + }, + isRangeStart: { + borderRadiusRight: 'none', + }, + isRangeEnd: { + borderRadiusLeft: 'none', + }, + isInRange: { + bg: 'text-disabled', + color: 'background', + borderRadius: 'none', + '&:hover, &:focus': { + bg: 'secondary-hover', + color: 'background', + }, + '&::after': { + bg: 'background', + }, + }, + isDisabled: { + color: 'text-disabled', + bg: 'transparent', + cursor: 'not-allowed', + userSelect: 'none', + textDecoration: 'line-through', + '&:hover, &:focus': { + color: 'text-disabled', + bg: 'transparent', + textDecoration: 'line-through', + cursor: 'not-allowed', + userSelect: 'none', + }, + }, +}); + +type DateCellProps = StyleProps; + +export const DateCell = styled.td( + css({ + fontWeight: 'base', + width: '32px', + height: '32px', + textAlign: 'center', + padding: 0, + position: 'relative', + borderRadius: 'md', + transition: transitionConcat( + ['border-color', 'color', 'background-color', 'box-shadow'], + 'fast', + 'ease-in' + ), + '&:hover': { + color: 'secondary', + bg: 'background-hover', + transition: transitionConcat( + ['background-color', 'box-shadow'], + 'fast', + 'ease-in' + ), + cursor: 'pointer', + }, + '&:focus-visible': { + color: 'secondary', + outline: 'none', + }, + '&:active': { + color: 'text', + }, + '&::before': { + content: '""', + position: 'absolute', + inset: -3, + borderRadius: 'lg', + border: 2, + opacity: 0, + zIndex: 0, + }, + '&:focus-visible::before': { + opacity: 1, + }, + }), + datecellStates +); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts new file mode 100644 index 00000000000..5f4c33feefa --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts @@ -0,0 +1,86 @@ +import type { IsoWeekday } from '../../../utils/locale'; +import { stringifyLocale } from '../../../utils/locale'; + +export const capitalizeFirst = ({ + str, + locale, +}: { + str: string; + locale: Intl.Locale; +}) => + str.length === 0 + ? str + : str[0].toLocaleUpperCase(stringifyLocale(locale)) + str.slice(1); + +export const formatMonthYear = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { + month: 'long', + year: 'numeric', + }).format(date); +}; + +/** + * Get weekday names for column headers or abbr attributes. + * Column order follows `firstWeekday` (ISO 1 = Monday … 7 = Sunday), matching `Intl.Locale#getWeekInfo().firstDay`. + * @param format - 'short' for abbreviated (e.g. "Su", "Mo"), 'long' for full (e.g. "Sunday", "Monday") + */ +export const getWeekdayNames = ({ + format, + locale, + firstWeekday, +}: { + format: 'short' | 'long'; + locale: Intl.Locale; + firstWeekday: IsoWeekday; +}) => { + const formatter = new Intl.DateTimeFormat(stringifyLocale(locale), { + weekday: format, + }); + const monday = new Date(2024, 0, 8); + const namesMonToSun = Array.from({ length: 7 }, (_, i) => { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + return formatter.format(date); + }); + return Array.from({ length: 7 }, (_, j) => { + const iso = ((firstWeekday - 1 + j) % 7) + 1; + return namesMonToSun[iso - 1]; + }); +}; + +export const getRelativeMonthLabels = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return { + nextMonth: capitalizeFirst({ str: rtf.format(1, 'month'), locale }), + lastMonth: capitalizeFirst({ str: rtf.format(-1, 'month'), locale }), + }; +}; + +export const getRelativeTodayLabel = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return capitalizeFirst({ str: rtf.format(0, 'day'), locale }); +}; + +export const formatDateForAriaLabel = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(date); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts new file mode 100644 index 00000000000..e2e7cdf2732 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts @@ -0,0 +1,156 @@ +import type { CalendarBodyProps } from '../types'; +import { type DateWithRow, isDateDisabled } from './dateGrid'; + +export type KeyHandlerParams = Pick< + CalendarBodyProps, + | 'onFocusedDateChange' + | 'onDateSelect' + | 'onDisplayDateChange' + | 'onEscapeKeyPress' + | 'hasAdjacentMonthRight' + | 'hasAdjacentMonthLeft' + | 'disableDate' +> & { + e: React.KeyboardEvent; + date: Date; + datesWithRow: DateWithRow[]; + month: number; + year: number; +}; + +/** + * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). + */ +const clampToMonth = (year: number, month: number, day: number) => { + const last = new Date(year, month + 1, 0).getDate(); + return new Date(year, month, Math.min(day, last)); +}; + +export const keyHandler = ({ + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, +}: KeyHandlerParams) => { + const idx = datesWithRow.findIndex( + ({ date: dateWithRow }) => dateWithRow.getTime() === date.getTime() + ); + if (idx < 0) return; + + const currentRow = datesWithRow[idx].rowIndex; + const day = date.getDate(); + const hasRight = !!hasAdjacentMonthRight; + const hasLeft = !!hasAdjacentMonthLeft; + + let newDate: Date | null = null; + let newDisplayDate: Date | null = null; + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + if (idx > 0) { + newDate = datesWithRow[idx - 1].date; + } else { + const lastDayPrevMonth = new Date(year, month, 0); + newDate = lastDayPrevMonth; + if (!hasLeft) { + newDisplayDate = new Date(year, month - 1, 1); + } + } + break; + case 'ArrowRight': + e.preventDefault(); + if (idx < datesWithRow.length - 1) { + newDate = datesWithRow[idx + 1].date; + } else { + newDate = new Date(year, month + 1, 1); + if (!hasRight) { + newDisplayDate = new Date(year, month + 1, 1); + } + } + break; + case 'ArrowUp': + e.preventDefault(); + newDate = new Date(date); + newDate.setDate(newDate.getDate() - 7); + if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { + if (!hasLeft) { + newDisplayDate = new Date( + newDate.getFullYear(), + newDate.getMonth(), + 1 + ); + } + } + break; + case 'ArrowDown': + e.preventDefault(); + newDate = new Date(date); + newDate.setDate(newDate.getDate() + 7); + if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { + if (!hasRight) { + newDisplayDate = new Date( + newDate.getFullYear(), + newDate.getMonth(), + 1 + ); + } + } + break; + case 'Home': + e.preventDefault(); + newDate = + datesWithRow.find(({ rowIndex }) => rowIndex === currentRow)?.date ?? + date; + break; + case 'End': + e.preventDefault(); + newDate = + [...datesWithRow] + .reverse() + .find(({ rowIndex }) => rowIndex === currentRow)?.date ?? date; + break; + case 'PageDown': + e.preventDefault(); + if (e.shiftKey) { + newDate = clampToMonth(year + 1, month, day); + } else { + newDate = clampToMonth(year, month + 1, day); + } + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'PageUp': + e.preventDefault(); + if (e.shiftKey) { + newDate = clampToMonth(year - 1, month, day); + } else { + newDate = clampToMonth(year, month - 1, day); + } + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (!isDateDisabled({ date, disableDate })) onDateSelect(date); + return; + case 'Escape': + e.preventDefault(); + onEscapeKeyPress?.(); + return; + default: + return; + } + + if (newDate !== null) { + onFocusedDateChange(newDate); + if (newDisplayDate !== null) onDisplayDateChange?.(newDisplayDate); + } +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx new file mode 100644 index 00000000000..4a7788ba591 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx @@ -0,0 +1,177 @@ +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FC } from 'react'; + +import { DatePickerProvider } from '../../DatePickerContext'; +import { + createMockRangeContext, + createMockSingleContext, +} from '../../DatePickerContext/__tests__/mockContexts'; +import type { DatePickerContextValue } from '../../DatePickerContext/types'; +import { DatePickerCalendar } from '..'; + +jest.mock('react-use', () => { + const actual = jest.requireActual('react-use'); + return { + ...actual, + /** One-month layout in tests (stable gridcell queries). */ + useMedia: jest.fn(() => false), + }; +}); + +type CalendarHarnessProps = { context: DatePickerContextValue }; + +const DatePickerCalendarHarness: FC = ({ context }) => ( + + + +); + +const renderCalendar = setupRtl(DatePickerCalendarHarness, { + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + }), +}); + +describe('DatePickerCalendar', () => { + it('throws when rendered without DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow(/useDatePickerContext must be used within a DatePicker/); + }); + + it('renders a calendar grid when the picker context is open', () => { + const { view } = renderCalendar(); + + expect(view.getByRole('grid')).toBeInTheDocument(); + }); + + it('calls setSelection when a day cell is activated in single mode', async () => { + const onSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + onSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onSelection).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('calls onSelection(null) when the already-selected day is clicked again in single mode', async () => { + const onSelection = jest.fn(); + const selected = new Date(2024, 2, 20); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: selected, + onSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onSelection).toHaveBeenCalledWith(null); + }); + + it('calls closeCalendar after selecting a date in single mode', async () => { + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + closeCalendar, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(closeCalendar).toHaveBeenCalled(); + }); + + it('invokes closeCalendar when Escape is pressed on the grid', async () => { + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + closeCalendar, + }), + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + march15.focus(); + await userEvent.keyboard('{Escape}'); + + expect(closeCalendar).toHaveBeenCalled(); + }); + + it('calls onRangeSelection with a new start when the start field is active and a day is chosen', async () => { + const onRangeSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'start', + onRangeSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onRangeSelection).toHaveBeenCalledWith(new Date(2024, 2, 20), null); + }); + + it('calls setSelection in range mode when choosing an end date with the end field active', async () => { + const onRangeSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'end', + onRangeSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onRangeSelection).toHaveBeenCalledWith( + new Date(2024, 2, 1), + new Date(2024, 2, 20) + ); + }); + + it('calls closeCalendar in range mode when a full range is selected', async () => { + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'end', + closeCalendar, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(closeCalendar).toHaveBeenCalled(); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx new file mode 100644 index 00000000000..5839ee6004f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -0,0 +1,331 @@ +import { breakpoints } from '@codecademy/gamut-styles'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { useMedia } from 'react-use'; + +import { Box, FlexBox } from '../../Box'; +import { useDatePicker } from '../DatePickerContext'; +import { CalendarQuickAction } from '../sharedTypes'; +import { + CalendarBody, + CalendarFooter, + CalendarHeader, + CalendarWrapper, +} from './Calendar'; +import type { CalendarBodyProps } from './Calendar/types'; +import { + addMonths, + getFirstOfMonth, + isDateWithinVisibleMonths, +} from './Calendar/utils/dateGrid'; +import { + applyRangeOrNewStart, + handleDateSelectRange, + handleDateSelectSingle, + rangeContainsDisabled, +} from './utils/dateSelect'; +import { computeQuickAction } from './utils/quickActions'; + +export type DatePickerCalendarProps = Pick< + CalendarBodyProps, + 'weekStartsOn' +> & { + /** id for the dialog (for aria-controls from input). */ + dialogId: string; +}; + +export const DatePickerCalendar: React.FC = ({ + dialogId, + weekStartsOn, +}) => { + const context = useDatePicker(); + const generatedId = useId(); + const headingLeftId = `datepicker-calendar-left-month-heading-${generatedId.replace( + /:/g, + '' + )}`; + const headingRightId = `datepicker-calendar-right-month-heading-${generatedId.replace( + /:/g, + '' + )}`; + + if (context === null) { + throw new Error( + 'DatePickerCalendar must be used inside a DatePicker (it reads shared state from context).' + ); + } + + const { + mode, + disableDate, + locale, + closeCalendar, + isCalendarOpen, + translations, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + quickActions, + } = context; + + /** Wraps both month grids so cross-grid roving and tab-from-nav still count as in-calendar. */ + const calendarContainerRef = useRef(null); + + const focusGridSync = useMemo( + () => ({ + gridFocusRequested, + signal: focusGridSignal, + onGridFocusRequestHandled: clearGridFocusRequest, + calendarContainerRef, + }), + [ + gridFocusRequested, + focusGridSignal, + clearGridFocusRequest, + calendarContainerRef, + ] + ); + + const isRange = mode === 'range'; + const selectedDate = isRange ? context.startDate : context.selectedDate; + const endDate = isRange ? context.endDate : undefined; + const setActiveRangePart = isRange ? context.setActiveRangePart : undefined; + const activeRangePart = isRange ? context.activeRangePart : null; + + const anchorDate = useMemo((): Date | null => { + if (!isRange) return selectedDate ?? null; + if (activeRangePart === 'end') return endDate ?? selectedDate ?? null; + return selectedDate ?? endDate ?? null; + }, [isRange, selectedDate, endDate, activeRangePart]); + + const [displayDate, setDisplayDate] = useState(() => + getFirstOfMonth(anchorDate ?? selectedDate ?? endDate ?? new Date()) + ); + const [focusedDate, setFocusedDate] = useState( + () => selectedDate ?? endDate ?? new Date() + ); + + const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); + const secondMonthDate = addMonths({ date: displayDate, n: 1 }); + const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.sm})`); + /** Current left-column month; read in the anchor sync effect without listing `displayDate` in deps (month nav would retrigger and snap back). */ + const startOfLeftVisibleMonthRef = useRef(displayDate); + startOfLeftVisibleMonthRef.current = displayDate; + + const [pauseGridRoving, setPauseGridRoving] = useState(false); + + const resumeGridRoving = useCallback(() => setPauseGridRoving(false), []); + + const beginHeaderMonthChange = useCallback( + (nextDisplay: (prev: Date) => Date) => { + setPauseGridRoving(true); + setDisplayDate(nextDisplay); + }, + [] + ); + + const onFocusedDateChangeFromGrid = useCallback( + (next: Date | null) => { + resumeGridRoving(); + setFocusedDate(next); + }, + [resumeGridRoving] + ); + + useEffect(() => { + if (gridFocusRequested) resumeGridRoving(); + }, [gridFocusRequested, resumeGridRoving]); + + const onTabFromMonthNav = useCallback(() => { + setFocusedDate(getFirstOfMonth(displayDate)); + resumeGridRoving(); + requestAnimationFrame(() => { + calendarContainerRef.current + ?.querySelector('[role="gridcell"][tabindex="0"]') + ?.focus(); + }); + }, [displayDate, resumeGridRoving]); + + useEffect(() => { + if (!isCalendarOpen || !anchorDate) { + return; + } + + const alreadyVisible = isDateWithinVisibleMonths({ + date: anchorDate, + startOfLeftVisibleMonth: startOfLeftVisibleMonthRef.current, + showSecondMonth: isTwoMonthsVisible, + }); + + if (!alreadyVisible) { + setDisplayDate(getFirstOfMonth(anchorDate)); + } + setFocusedDate(anchorDate); + }, [isCalendarOpen, anchorDate, isTwoMonthsVisible]); + + const onDateSelect = useCallback( + (date: Date) => { + if (!isRange) { + handleDateSelectSingle({ + date, + selectedDate: context.selectedDate, + onSelection: context.onSelection, + }); + queueMicrotask(closeCalendar); + return; + } + setActiveRangePart?.(null); + const shouldClose = handleDateSelectRange({ + date, + activeRangePart: context.activeRangePart, + startDate: context.startDate, + endDate: context.endDate, + onRangeSelection: context.onRangeSelection, + disableDate, + }); + if (shouldClose) queueMicrotask(closeCalendar); + }, + [isRange, setActiveRangePart, context, disableDate, closeCalendar] + ); + + const clearDate = useCallback(() => { + if (isRange) context.onRangeSelection(null, null); + else context.onSelection(null); + resumeGridRoving(); + setFocusedDate(displayDate); + }, [isRange, context, setFocusedDate, displayDate, resumeGridRoving]); + + const computedQuickActions: CalendarQuickAction[] = useMemo(() => { + return quickActions.slice(0, 3).map((action) => ({ + ...action, + onClick: () => { + action.onClick?.(); + setActiveRangePart?.(null); + const { startDate, endDate } = computeQuickAction({ + num: action.num, + timePeriod: action.timePeriod, + isRange, + }); + if (isRange) { + if ( + rangeContainsDisabled({ + startDate, + endDate, + disableDate, + }) + ) { + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: endDate, + disableDate, + onRangeSelection: context.onRangeSelection, + }); + } else { + context.onRangeSelection(startDate, endDate); + } + setDisplayDate(getFirstOfMonth(endDate)); + setFocusedDate(endDate); + queueMicrotask(closeCalendar); + } else { + context.onSelection(startDate); + setDisplayDate(getFirstOfMonth(startDate)); + setFocusedDate(startDate); + queueMicrotask(closeCalendar); + } + }, + })); + }, [ + closeCalendar, + disableDate, + isRange, + quickActions, + setActiveRangePart, + context, + ]); + + return ( + + + + beginHeaderMonthChange(() => next)} + onTabIntoGrid={onTabFromMonthNav} + /> + + + + + beginHeaderMonthChange((prev) => addMonths({ date: prev, n: 1 })) + } + onTabIntoGrid={onTabFromMonthNav} + /> + + setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) + } + onEscapeKeyPress={closeCalendar} + onFocusedDateChange={onFocusedDateChangeFromGrid} + /> + + + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts new file mode 100644 index 00000000000..3c4a14c70d1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts @@ -0,0 +1,660 @@ +import { matchDisabledDates } from '../../Calendar/utils/dateGrid'; +import { + applyRangeOrNewStart, + handleDateSelectRange, + handleDateSelectSingle, + rangeContainsDisabled, +} from '../dateSelect'; + +const createDate = (y: number, month: number, day: number) => + new Date(y, month, day); + +const mockOnSelection = jest.fn(); +const mockOnRangeSelection = jest.fn(); +describe('rangeContainsDisabled', () => { + const startDate = createDate(2024, 0, 10); + const endDate = createDate(2024, 0, 20); + + it('returns true when a disabled date is the start date', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 10)]), + }) + ).toBe(true); + }); + + it('returns true when a disabled date is the end date', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 20)]), + }) + ).toBe(true); + }); + + it('returns true when a disabled date is strictly between startDate and endDate', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 15)]), + }) + ).toBe(true); + }); + + it('returns false when no disabled date touches the inclusive range', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([ + createDate(2024, 0, 5), + createDate(2024, 0, 25), + ]), + }) + ).toBe(false); + }); + + it('returns true when disableDate marks a day inside the inclusive range', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: (d) => d.getDate() === 15, + }) + ).toBe(true); + }); +}); + +describe('handleDateSelectSingle', () => { + it('clears selection when the same date is clicked again', () => { + const selected = createDate(2024, 5, 15); + handleDateSelectSingle({ + date: selected, + selectedDate: selected, + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(null); + }); + + it('sets selection when no date was previously selected', () => { + const newSelected = createDate(2024, 5, 10); + handleDateSelectSingle({ + date: newSelected, + selectedDate: null, + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); + }); + + it('sets selection to a new day when a date was previously selected', () => { + const newSelected = createDate(2024, 5, 20); + handleDateSelectSingle({ + date: newSelected, + selectedDate: createDate(2024, 5, 15), + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); + }); +}); + +describe('applyRangeOrNewStart', () => { + it('sets selection to the start and end date when the range does not contain a disabled date', () => { + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + expect( + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: clicked, + disableDate: matchDisabledDates([createDate(2024, 5, 30)]), + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(true); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, endDate); + }); + + it('sets selection to the clicked date as start and null as end when the range contains a disabled date', () => { + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + expect( + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: clicked, + disableDate: matchDisabledDates([createDate(2024, 5, 12)]), + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(false); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); +}); + +describe('handleDateSelectRange', () => { + describe('close calendar return value', () => { + it('returns false when only a start date is chosen (calendar mode)', () => { + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 10), + activeRangePart: null, + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(false); + expect(mockOnRangeSelection).toHaveBeenCalledWith( + createDate(2024, 5, 10), + null + ); + }); + + it('returns true when end date is chosen after start (calendar mode)', () => { + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 20), + activeRangePart: null, + startDate: createDate(2024, 5, 10), + endDate: null, + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(true); + expect(mockOnRangeSelection).toHaveBeenCalledWith( + createDate(2024, 5, 10), + createDate(2024, 5, 20) + ); + }); + }); + + describe('activeRangePart === start', () => { + describe('start date is set', () => { + it('clears start when the start date is clicked again', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); + handleDateSelectRange({ + date: startDate, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, endDate); + }); + + it('sets start date when no end date is set', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + describe('start date is not set', () => { + it('sets start date when no end date is set', () => { + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + }); + + describe('activeRangePart === end', () => { + describe('end date is set', () => { + it('clears end date when the end date is clicked again', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 18); + handleDateSelectRange({ + date: endDate, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); + }); + + it('sets end date when no start date is set', () => { + const endDate = createDate(2024, 2, 18); + const clicked = createDate(2024, 2, 19); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const startDate = createDate(2024, 2, 20); + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + describe('end date is not set', () => { + it('sets end date when no start date is set', () => { + const clicked = createDate(2024, 2, 19); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const startDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + }); + + describe('selection mode (activeRangePart null) with start date and end date set', () => { + it('clears when a single-day range is clicked again', () => { + const day = createDate(2024, 2, 14); + handleDateSelectRange({ + date: day, + activeRangePart: null, + startDate: day, + endDate: day, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, null); + }); + + it('end date becomes start date when start date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); + handleDateSelectRange({ + date: startDate, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(endDate, null); + }); + + it('clears end date when end date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); + handleDateSelectRange({ + date: endDate, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); + }); + + it('updates end date when a date after start date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('updates start date to the clicked date and clears end date when the range extending to right would contain a disabled date', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date when a date before start date is clicked', () => { + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('updates start date to the clicked date and clears end date when the range extending to left would contain a disabled date', () => { + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + + describe('start date set, end date empty, selection mode', () => { + it('updates start date when clicked date is before start date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets end date when clicked date is on or after start date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('updates start date to the clicked date and does not set end date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + + describe('start date empty, end date set, selection mode', () => { + it('sets start date and clears end datewhen clicked date is before end date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when clicked date is after end date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date to the clicked date and clears end date when the range would contain a disabled date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts new file mode 100644 index 00000000000..bd5a664e3ab --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts @@ -0,0 +1,90 @@ +import { computeQuickAction } from '../quickActions'; + +describe('computeQuickAction', () => { + const fixed = new Date(2026, 4, 15); // May 15, 2026 local (“today” anchor) + + describe('range mode (isRange: true)', () => { + it('returns [startDate, endDate] with endDate = anchor day when range is entirely in the past', () => { + const { startDate, endDate } = computeQuickAction({ + num: -30, + timePeriod: 'day', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 3, 15)); + }); + + it('applies month as num * 30 rolling days', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'month', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 3, 15)); + }); + + it('applies multiple months as additional 30-day steps', () => { + const { startDate, endDate } = computeQuickAction({ + num: -2, + timePeriod: 'month', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 60); + expect(startDate).toEqual(expected); + }); + + it('applies year as num * 365 rolling days', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'year', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 365); + expect(startDate).toEqual(expected); + }); + + it('when computed start is after anchor day, orders as [anchor, computed] so the range is forward', () => { + const { startDate, endDate } = computeQuickAction({ + num: 5, + timePeriod: 'day', + isRange: true, + now: fixed, + }); + expect(startDate).toEqual(new Date(2026, 4, 15)); + expect(endDate).toEqual(new Date(2026, 4, 20)); + }); + }); + + describe('single mode (isRange: false)', () => { + it('does not swap when computed day is after anchor; endDate is still the anchor day', () => { + const { startDate, endDate } = computeQuickAction({ + num: 1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 4, 16)); + }); + + it('returns past startDate with endDate = anchor for yesterday', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 4, 14)); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts new file mode 100644 index 00000000000..77425c45b03 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -0,0 +1,200 @@ +import type { + DatePickerRangeContextValue, + DatePickerSingleContextValue, +} from '../../DatePickerContext/types'; +import { DatePickerSharedProps } from '../../sharedTypes'; +import type { DatePickerProps, DatePickerRangeProps } from '../../types'; +import { + getOrderedCalendarEndpoints, + isDateDisabled, + isDateInRange, + isSameDay, +} from '../Calendar/utils/dateGrid'; + +export const isRangeProps = ( + props: DatePickerProps +): props is DatePickerRangeProps => props.mode === 'range'; + +type RangeContainsDisabledParams = { + startDate: Date; + endDate: Date; +} & Pick; + +export const rangeContainsDisabled = ({ + startDate, + endDate, + disableDate, +}: RangeContainsDisabledParams) => { + const { low, high } = getOrderedCalendarEndpoints({ startDate, endDate }); + + if ( + isDateDisabled({ date: low, disableDate }) || + isDateDisabled({ date: high, disableDate }) + ) { + return true; + } + + let date = new Date(low.getFullYear(), low.getMonth(), low.getDate() + 1); + while (isDateInRange({ date, startDate, endDate })) { + if (isDateDisabled({ date, disableDate })) { + return true; + } + date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); + } + return false; +}; + +type HandleDateSelectSingleParams = { + date: Date; +} & Pick; + +export const handleDateSelectSingle = ({ + date, + selectedDate, + onSelection, +}: HandleDateSelectSingleParams) => { + if (isSameDay(date, selectedDate)) { + onSelection(null); + return; + } + onSelection(date); +}; + +type ApplyRangeOrNewStartParams = { + startDate: Date; + endDate: Date; + clickedDate: Date; +} & Pick; + +/** @returns whether a full startDate+endDate range was committed (calendar may close). */ +export const applyRangeOrNewStart = ({ + startDate, + endDate, + clickedDate, + disableDate, + onRangeSelection, +}: ApplyRangeOrNewStartParams) => { + if (rangeContainsDisabled({ startDate, endDate, disableDate })) { + onRangeSelection(clickedDate, null); + return false; + } + onRangeSelection(startDate, endDate); + return true; +}; + +type HandleDateSelectRangeParams = { + date: Date; +} & Pick< + DatePickerRangeContextValue, + | 'activeRangePart' + | 'endDate' + | 'onRangeSelection' + | 'disableDate' + | 'startDate' +>; + +/** @returns whether the calendar should close (full range selected and committed). */ +export const handleDateSelectRange = ({ + date, + activeRangePart, + startDate, + endDate, + onRangeSelection, + disableDate, +}: HandleDateSelectRangeParams) => { + // Field targeting: start or end input was focused + if (activeRangePart === 'start') { + if (isSameDay(date, startDate)) { + onRangeSelection(null, endDate); + return false; + } + const newEndDate = + endDate !== null && date.getTime() <= endDate.getTime() ? endDate : null; + if (newEndDate !== null) { + return applyRangeOrNewStart({ + startDate: date, + endDate: newEndDate, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(date, newEndDate); + return false; + } + if (activeRangePart === 'end') { + if (isSameDay(date, endDate)) { + onRangeSelection(startDate, null); + return false; + } + const newStartDate = + startDate !== null && date.getTime() >= startDate.getTime() + ? startDate + : null; + if (newStartDate !== null) { + return applyRangeOrNewStart({ + startDate: newStartDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(newStartDate, date); + return false; + } + + // Selection mode (no field focused: calendar drives both) + if (startDate && endDate) { + if (isSameDay(startDate, endDate) && isSameDay(date, startDate)) { + onRangeSelection(null, null); + return false; + } + // if clicked on start date, end date becomes start date + if (isSameDay(date, startDate)) { + onRangeSelection(endDate, null); + return false; + } + // if clicked on end date, clears end date and start remains + if (isSameDay(date, endDate)) { + onRangeSelection(startDate, null); + return false; + } + // If clicked date > Start: Updates End Date to new date (Start remains) + if (date.getTime() > startDate.getTime()) { + return applyRangeOrNewStart({ + startDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left + return applyRangeOrNewStart({ + startDate: date, + endDate, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + // Start is Set, End is Empty + if (startDate && !endDate) { + // If clicked date < Start: Restarts selection with clicked date as new Start + if (date.getTime() < startDate.getTime()) { + onRangeSelection(date, null); + return false; + } + // If clicked date > Start: Sets it as End Date (if range valid) + return applyRangeOrNewStart({ + startDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(date, null); + return false; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts new file mode 100644 index 00000000000..9cb87101ea7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts @@ -0,0 +1,106 @@ +import { CalendarQuickAction } from '../../sharedTypes'; +import { stringifyLocale } from '../../utils/locale'; +import { DatePickerTranslations } from '../../utils/translations'; +import { capitalizeFirst } from '../Calendar/utils/format'; + +const getRelativeDisplayText = ({ + num, + timePeriod, + locale, +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + locale: Intl.Locale; +}) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return capitalizeFirst({ str: rtf.format(num, timePeriod), locale }); +}; + +export const getDefaultSingleQuickActions = ( + locale: Intl.Locale +): CalendarQuickAction[] => [ + { + num: -1, + timePeriod: 'day', + displayText: getRelativeDisplayText({ + num: -1, + timePeriod: 'day', + locale, + }), + }, + { + num: 0, + timePeriod: 'day', + displayText: getRelativeDisplayText({ num: 0, timePeriod: 'day', locale }), + }, + { + num: 1, + timePeriod: 'day', + displayText: getRelativeDisplayText({ num: 1, timePeriod: 'day', locale }), + }, +]; + +export const getDefaultRangeQuickActions = ( + translations: Required +): CalendarQuickAction[] => [ + { + num: -7, + timePeriod: 'day', + displayText: translations.last7DaysDisplayText, + }, + { + num: -30, + timePeriod: 'day', + displayText: translations.last30DaysDisplayText, + }, + { + num: -90, + timePeriod: 'day', + displayText: translations.last90DaysDisplayText, + }, +]; + +export const computeQuickAction = ({ + num, + timePeriod, + isRange, + now = new Date(), +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + isRange: boolean; + now?: Date; +}) => { + const anchorDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let startDate: Date; + + switch (timePeriod) { + case 'day': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num); + break; + } + case 'week': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 7); + break; + } + case 'month': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 30); + break; + } + case 'year': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 365); + break; + } + } + + if (isRange && startDate.getTime() > anchorDate.getTime()) { + return { startDate: anchorDate, endDate: startDate }; + } + return { startDate, endDate: anchorDate }; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx new file mode 100644 index 00000000000..a3db97457f7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx @@ -0,0 +1,78 @@ +import { MockGamutProvider } from '@codecademy/gamut-tests'; +import { render } from '@testing-library/react'; + +import { DatePickerProvider, useDatePicker } from '..'; +import { + createMockRangeContext, + createMockSingleContext, +} from './mockContexts'; + +const Consumer = () => { + const ctx = useDatePicker(); + return {ctx.mode}; +}; + +const RangeDatesConsumer = () => { + const ctx = useDatePicker(); + if (ctx.mode !== 'range') return null; + return ( + + {ctx.startDate?.toDateString() ?? 'no-start'}| + {ctx.endDate?.toDateString() ?? 'no-end'} + + ); +}; + +describe('DatePickerContext', () => { + it('throws when useDatePicker is used outside DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow('useDatePickerContext must be used within a DatePicker.'); + }); + + it('returns single-mode context from useDatePicker when wrapped in DatePickerProvider', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('single'); + }); + + it('exposes range fields when the provider value is range mode', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('range'); + }); + + it('passes startDate and endDate through to consumers in range mode', () => { + const start = new Date(2026, 3, 10); + const end = new Date(2026, 3, 20); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('range-dates')).toHaveTextContent( + `${start.toDateString()}|${end.toDateString()}` + ); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts new file mode 100644 index 00000000000..33ea42ef641 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts @@ -0,0 +1,50 @@ +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; +import type { + DatePickerRangeContextValue, + DatePickerSingleContextValue, +} from '../types'; + +export function createMockSingleContext( + overrides: Partial = {} +): DatePickerSingleContextValue { + return { + mode: 'single', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + selectedDate: new Date(2024, 2, 15), + onSelection: jest.fn(), + ...overrides, + }; +} + +export function createMockRangeContext( + overrides: Partial = {} +): DatePickerRangeContextValue { + return { + mode: 'range', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + startDate: null, + endDate: null, + onRangeSelection: jest.fn(), + activeRangePart: null, + setActiveRangePart: jest.fn(), + ...overrides, + }; +} diff --git a/packages/gamut/src/DatePicker/DatePickerContext/index.tsx b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx new file mode 100644 index 00000000000..725c4badd16 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { DatePickerContext, DatePickerContextValue } from './types'; + +export const DatePickerProvider = DatePickerContext.Provider; + +export const useDatePicker = (): DatePickerContextValue => { + const value = useContext(DatePickerContext); + if (value === null) { + throw new Error('useDatePickerContext must be used within a DatePicker.'); + } + return value; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerContext/types.ts b/packages/gamut/src/DatePicker/DatePickerContext/types.ts new file mode 100644 index 00000000000..cc389358e0e --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -0,0 +1,109 @@ +import { createContext } from 'react'; + +import { CalendarQuickAction, DatePickerSharedProps } from '../sharedTypes'; +import type { DatePickerTranslations } from '../utils/translations'; + +interface DatePickerBaseContextValue + extends Pick { + /** + * Discriminator: same meaning as the `mode` prop on `DatePicker` (`"single"` or `"range"`). + */ + mode: Mode; + /** + * Resolved `Intl.Locale` for the `locale` prop (or the runtime default). The same object is + * passed to formatters and to APIs such as `getWeekInfo` where available. + */ + locale: Intl.Locale; + /** + * Whether the calendar popover (dialog) is open. + * + */ + isCalendarOpen: boolean; + /** + * Function to open the calendar popover. Use within callbacks that should open the calendar. + */ + openCalendar: () => void; + /** Function to move focus into the calendar grid. */ + focusCalendar: () => void; + /** + * Whether a grid-focus request is issued with an unchanged `focusedDate`, so layout + * effects that depend on focus can still re-run. + */ + focusGridSignal: boolean; + /** + * Whether the shell should run a one-shot move of focus into the grid. + * Clear with {@link clearGridFocusRequest} after focus moves. + */ + gridFocusRequested: boolean; + /** + * Mark the grid focus request as handled. Call after the calendar has moved focus into the + * grid, or to reset state without closing. + */ + clearGridFocusRequest: () => void; + /** + * Function to close the calendar popover and return focus to the input. Use within callbacks that should close the calendar. + */ + closeCalendar: () => void; + /** + * Merged `translations` for the `DatePicker` component. The `DatePicker` `translations` prop is merged onto + * the default strings so every key is present. See {@link DatePickerTranslations} for the shape of the translations and default values. + */ + translations: Required; + /** + * Footer quick actions. The shell uses an empty array if the + * `DatePicker` `quickActions` prop is `null`. When the prop is omitted, built-in defaults + * apply. See {@link CalendarQuickAction} for the shape of the quick actions. + */ + quickActions: CalendarQuickAction[]; +} + +export interface DatePickerSingleContextValue + extends DatePickerBaseContextValue<'single'> { + /** + * The controlled selected date. Same as the `DatePicker` `selectedDate` prop. + */ + selectedDate: Date | null; + /** + * Callback to update the selected date. Forwards to the `onSelected` callback on `DatePicker`. + */ + onSelection: (date: Date | null) => void; +} + +type ActiveRangePart = 'start' | 'end' | null; + +export interface DatePickerRangeContextValue + extends DatePickerBaseContextValue<'range'> { + /** + * Controlled start of the range. Same as the `DatePicker` `startDate` prop. + */ + startDate: Date | null; + /** + * Controlled end of the range. Same as the `DatePicker` `endDate` prop. + */ + endDate: Date | null; + /** + * Updates both `startDate` and `endDate` by calling the `onStartSelected` and `onEndSelected` + * props on `DatePicker` in one step. + */ + onRangeSelection: (startDate: Date | null, endDate: Date | null) => void; + /** + * `"start"` or `"end"` when that segment of the field drives the next interaction, or `null` + * in a combined "selection" state (for example when extending or replacing the range from the + * grid). Affects the visible anchor month in the open calendar and how a day pick updates + * start vs. end in range mode. + */ + activeRangePart: ActiveRangePart; + /** + * Set {@link activeRangePart} (for example when focus moves between the start and end + * spinbutton inputs). + */ + setActiveRangePart: (part: ActiveRangePart) => void; +} + +export type DatePickerContextValue = + | DatePickerSingleContextValue + | DatePickerRangeContextValue; + +export const DatePickerContext = createContext( + null +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx new file mode 100644 index 00000000000..23efda99da4 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx @@ -0,0 +1,183 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; +import { type FC, useCallback, useState } from 'react'; + +import type { DatePartKind } from '../../utils'; +import { type AssignSegmentRef, DatePickerInputSegment } from '..'; +import type { SegmentValues } from '../utils'; + +const noop = () => undefined; + +const noopAssignSegmentRef: AssignSegmentRef = () => undefined; + +const noopFocusSegmentField = () => undefined; + +type HarnessProps = { + field?: DatePartKind; + segment?: SegmentValues; + disabled?: boolean; + error?: boolean; + focusOrOpenCalendarGrid?: () => void; +}; + +/** + * Real `useState` + `setSegments` so functional updaters from ArrowUp / typing run and re-render. + */ +const SegmentHarness: FC = ({ + field = 'month', + segment = { month: '', day: '', year: '' }, + disabled = false, + error = false, + focusOrOpenCalendarGrid = noop, +}) => { + const [segments, setSegments] = useState(segment); + const applySegments = useCallback(() => { + // Parent would commit parsed date; segment tests only need state updates via setSegments. + }, []); + + return ( + + ); +}; + +const renderView = setupRtl(SegmentHarness, {}); + +describe('DatePickerInputSegment', () => { + it.each([ + ['month', 'MM'], + ['day', 'DD'], + ['year', 'YYYY'], + ] as const)('shows placeholder for %s', (field, expected) => { + const { view } = renderView({ field }); + + expect(view.getByRole('spinbutton', { name: field })).toHaveAttribute( + 'aria-valuetext', + expected + ); + }); + + it('sets aria-invalid when error is true', () => { + const { view } = renderView({ error: true }); + + expect(view.getByRole('spinbutton', { name: 'month' })).toHaveAttribute( + 'aria-invalid', + 'true' + ); + }); + + it('sets aria-disabled and tabIndex -1 when disabled', () => { + const { view } = renderView({ disabled: true }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + expect(month).toHaveAttribute('aria-disabled', 'true'); + expect(month).toHaveAttribute('tabIndex', '-1'); + }); + + it('ignores digit input when disabled', async () => { + const user = userEvent.setup(); + const { view } = renderView({ disabled: true }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('5'); + + expect(month).toHaveAttribute('aria-valuetext', 'MM'); + }); + + it('increments month with ArrowUp from empty', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{ArrowUp}'); + + expect(month).toHaveAttribute('aria-valuetext', '01'); + }); + + it('increments month with ArrowUp from 01', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '01', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{ArrowUp}'); + + expect(month).toHaveAttribute('aria-valuetext', '02'); + }); + + it('decrements month with ArrowDown from empty', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{ArrowDown}'); + + expect(month).toHaveAttribute('aria-valuetext', '12'); + }); + + it('decrements month with ArrowDown from 02', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '02', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{ArrowDown}'); + + expect(month).toHaveAttribute('aria-valuetext', '01'); + }); + + it('calls focusOrOpenCalendarGrid on Alt+ArrowDown', async () => { + const user = userEvent.setup(); + const focusOrOpenCalendarGrid = jest.fn(); + const { view } = renderView({ focusOrOpenCalendarGrid }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{Alt>}{ArrowDown}{/Alt}'); + + expect(focusOrOpenCalendarGrid).toHaveBeenCalledTimes(1); + }); + + it('appends typed digits until the segment is full', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('03'); + + expect(month).toHaveAttribute('aria-valuetext', '03'); + }); + + it('removes the last digit with Backspace', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '03', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await user.keyboard('{Backspace}'); + + expect(month).toHaveAttribute('aria-valuetext', '0'); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts new file mode 100644 index 00000000000..49d1da3376f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts @@ -0,0 +1,450 @@ +import type { DateFormatLayoutItem } from '../../utils'; +import { + appendSegmentDigit, + buildCombinedFromSegments, + digitsToSegments, + getDateSegmentsFromDate, + getSegmentSpinBounds, + getStrictSegmentDigits, + isStrictlyCompleteDateEntry, + normalizeSegmentValues, + padSegmentNumber, + parseSegmentNumericString, + parseSegmentsToDate, + spinSegment, +} from '../utils'; + +describe('getDateSegmentsFromDate', () => { + it('returns empty strings for null', () => { + expect(getDateSegmentsFromDate(null)).toEqual({ + month: '', + day: '', + year: '', + }); + }); + + it('pads month and day to two digits', () => { + expect(getDateSegmentsFromDate(new Date(2024, 2, 5))).toEqual({ + month: '03', + day: '05', + year: '2024', + }); + }); +}); + +describe('parseSegmentsToDate', () => { + it('returns date when all segments are valid', () => { + expect( + parseSegmentsToDate({ month: '03', day: '15', year: '2024' }) + ).toEqual(new Date(2024, 2, 15)); + }); + + it('returns null unless year has four digits', () => { + expect( + parseSegmentsToDate({ month: '03', day: '15', year: '24' }) + ).toBeNull(); + }); + + it('returns null when month or day is empty', () => { + expect( + parseSegmentsToDate({ month: '', day: '15', year: '2024' }) + ).toBeNull(); + expect( + parseSegmentsToDate({ month: '03', day: '', year: '2024' }) + ).toBeNull(); + }); + + it('returns null for invalid month', () => { + expect( + parseSegmentsToDate({ month: '13', day: '01', year: '2024' }) + ).toBeNull(); + }); + + it('returns null for invalid calendar day (rollover)', () => { + expect( + parseSegmentsToDate({ month: '02', day: '30', year: '2023' }) + ).toBeNull(); + }); + + it('accepts Feb 29 in a leap year', () => { + expect( + parseSegmentsToDate({ month: '02', day: '29', year: '2024' }) + ).toEqual(new Date(2024, 1, 29)); + }); +}); + +describe('getStrictSegmentDigits', () => { + it('strips non-digits and truncates to field widths', () => { + expect( + getStrictSegmentDigits({ month: '1a2', day: '3-4', year: '20xx24' }) + ).toEqual({ month: '12', day: '34', year: '2024' }); + }); +}); + +describe('isStrictlyCompleteDateEntry', () => { + it('is true for 2+2+4 digit strings', () => { + expect( + isStrictlyCompleteDateEntry({ month: '03', day: '15', year: '2024' }) + ).toBe(true); + }); + + it('is false for incomplete strings', () => { + expect( + isStrictlyCompleteDateEntry({ month: '3', day: '15', year: '2024' }) + ).toBe(false); + expect( + isStrictlyCompleteDateEntry({ month: '03', day: '15', year: '24' }) + ).toBe(false); + }); +}); + +describe('normalizeSegmentValues', () => { + describe('when entry is strictly complete', () => { + it('returns canonical segments when strict entry is a valid date', () => { + expect( + normalizeSegmentValues({ month: '03', day: '15', year: '2024' }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); + + it('returns empty segments when strict entry is an invalid calendar date', () => { + expect( + normalizeSegmentValues({ month: '02', day: '30', year: '2023' }) + ).toEqual({ month: '', day: '', year: '' }); + }); + }); + + describe('when entry is not strictly complete', () => { + it('pads a partial month', () => { + expect( + normalizeSegmentValues({ month: '3', day: '19', year: '2024' }) + ).toEqual({ + month: '03', + day: '19', + year: '2024', + }); + }); + + it('pads a partial day when year and month are complete', () => { + expect( + normalizeSegmentValues({ month: '03', day: '9', year: '2024' }) + ).toEqual({ + month: '03', + day: '09', + year: '2024', + }); + }); + + it('pads a partial day when year and month are not complete', () => { + expect( + normalizeSegmentValues({ month: '03', day: '9', year: '20' }) + ).toEqual({ + month: '03', + day: '09', + year: '20', + }); + }); + + it('clamps partial month to 1-12', () => { + expect( + normalizeSegmentValues({ month: '99', day: '', year: '' }) + ).toEqual(expect.objectContaining({ month: '12' })); + expect( + normalizeSegmentValues({ month: '00', day: '', year: '' }) + ).toEqual(expect.objectContaining({ month: '01' })); + }); + + it('clamps day to the last day of the month', () => { + // Single-digit month avoids strict 2/2/4 path; Feb 31 → 29 in 2024. + expect( + normalizeSegmentValues({ month: '2', day: '31', year: '2024' }) + ).toEqual({ + month: '02', + day: '29', + year: '2024', + }); + }); + + it('clamps partial day to 1-31 when year/month not both complete', () => { + expect( + normalizeSegmentValues({ month: '06', day: '99', year: '20' }) + ).toEqual(expect.objectContaining({ day: '31' })); + }); + + it('strips non-digit characters from partial input', () => { + expect( + normalizeSegmentValues({ month: '1a', day: '2b', year: '20c24' }) + ).toEqual({ + month: '01', + day: '02', + year: '2024', + }); + }); + }); +}); + +describe('getSegmentSpinBounds', () => { + it('bounds month to 1-12', () => { + expect( + getSegmentSpinBounds({ + field: 'month', + segments: { month: '', day: '', year: '' }, + }) + ).toEqual({ min: 1, max: 12 }); + }); + + it('bounds year to 1-9999', () => { + expect( + getSegmentSpinBounds({ + field: 'year', + segments: { month: '', day: '', year: '' }, + }) + ).toEqual({ min: 1, max: 9999 }); + }); + + it('bounds day using parsed month and four-digit year', () => { + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '2024', day: '' }, + }) + ).toEqual({ min: 1, max: 29 }); + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '2023', day: '' }, + }) + ).toEqual({ min: 1, max: 28 }); + }); + + it('uses default year 2024 when year segment is incomplete', () => { + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '20', day: '' }, + }) + ).toEqual({ min: 1, max: 29 }); + }); +}); + +describe('parseSegmentNumericString', () => { + it('returns null for empty or non-numeric', () => { + expect(parseSegmentNumericString('')).toBeNull(); + expect(parseSegmentNumericString('abc')).toBeNull(); + }); + + it('parses first digit run', () => { + expect(parseSegmentNumericString('12')).toBe(12); + expect(parseSegmentNumericString('3x4')).toBe(34); + }); +}); + +describe('padSegmentNumber', () => { + it('pads year to four digits', () => { + expect(padSegmentNumber({ field: 'year', numericValue: 123 })).toBe('0123'); + }); + it('pads month to two digits', () => { + expect(padSegmentNumber({ field: 'month', numericValue: 3 })).toBe('03'); + }); + + it('pads day to two digits', () => { + expect(padSegmentNumber({ field: 'day', numericValue: 1 })).toBe('01'); + }); +}); + +describe('appendSegmentDigit', () => { + it('ignores non-digit characters', () => { + expect(appendSegmentDigit({ field: 'month', prev: '01', digit: 'x' })).toBe( + '01' + ); + }); + + it('appends until max length', () => { + expect(appendSegmentDigit({ field: 'month', prev: '', digit: '1' })).toBe( + '1' + ); + expect(appendSegmentDigit({ field: 'month', prev: '1', digit: '2' })).toBe( + '12' + ); + }); + + it('replaces when segment is already full', () => { + expect(appendSegmentDigit({ field: 'month', prev: '12', digit: '5' })).toBe( + '5' + ); + expect( + appendSegmentDigit({ field: 'year', prev: '2024', digit: '9' }) + ).toBe('9'); + }); + + it('strips non-digits from previous value before appending', () => { + expect(appendSegmentDigit({ field: 'day', prev: '1a', digit: '2' })).toBe( + '12' + ); + }); +}); + +describe('spinSegment', () => { + const empty = { month: '', day: '', year: '' }; + + beforeEach(() => { + jest.spyOn(Date.prototype, 'getFullYear').mockReturnValue(2024); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses current calendar year when stepping up', () => { + expect(spinSegment({ field: 'year', segments: empty, delta: 1 })).toBe( + '2024' + ); + }); + + it('uses max year when stepping down', () => { + expect(spinSegment({ field: 'year', segments: empty, delta: -1 })).toBe( + '9999' + ); + }); + + it('steps month up from empty to min', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); + }); + + it('steps month down from empty to max', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('12'); + }); + + it('steps day up from empty to min', () => { + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); + }); + + it('steps day down from empty to max', () => { + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('31'); + }); + + it('increments within bounds', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '06', day: '', year: '' }, + delta: 1, + }) + ).toBe('07'); + expect( + spinSegment({ + field: 'month', + segments: { month: '12', day: '', year: '' }, + delta: 1, + }) + ).toBe('12'); + }); + + it('decrements within bounds', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '06', day: '', year: '' }, + delta: -1, + }) + ).toBe('05'); + expect( + spinSegment({ + field: 'month', + segments: { month: '01', day: '', year: '' }, + delta: -1, + }) + ).toBe('01'); + }); +}); + +describe('buildCombinedFromSegments', () => { + it('joins fields and literals in layout order (US)', () => { + const usLayout: DateFormatLayoutItem[] = [ + { kind: 'field', field: 'month' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'day' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'year' }, + ]; + + expect( + buildCombinedFromSegments({ + segments: { month: '03', day: '15', year: '2024' }, + layout: usLayout, + }) + ).toBe('03/15/2024'); + }); + + it('joins fields and literals in layout order (UK)', () => { + const ukLayout: DateFormatLayoutItem[] = [ + { kind: 'field', field: 'day' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'month' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'year' }, + ]; + + expect( + buildCombinedFromSegments({ + segments: { month: '03', day: '15', year: '2024' }, + layout: ukLayout, + }) + ).toBe('15/03/2024'); + }); +}); + +describe('digitsToSegments', () => { + it('splits digit string by field order (MDY)', () => { + expect( + digitsToSegments({ + digits: '03152024', + fieldOrder: ['month', 'day', 'year'], + }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); + + it('splits digit string by field order (DMY)', () => { + expect( + digitsToSegments({ + digits: '15032024', + fieldOrder: ['day', 'month', 'year'], + }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx new file mode 100644 index 00000000000..2428e02116a --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -0,0 +1,43 @@ +import { css, states } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +const segmentStyles = states({ + isEmpty: { + color: 'text-secondary', + }, + isYear: { + minWidth: '4ch', + }, +}); + +type SegmentStyleProps = StyleProps; + +export const Segment = styled.span( + css({ + display: 'inline-block', + textAlign: 'center', + minWidth: '2ch', + padding: 0, + margin: 0, + color: 'text', + cursor: 'text', + '&:focus': { + bg: 'primary', + color: 'background', + borderRadius: 'md', + }, + '&:focus-visible': { + outline: 'none', + }, + }), + segmentStyles +); + +export const SegmentLiteral = styled.span( + css({ + color: 'text-secondary', + userSelect: 'none', + px: 4, + }) +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx new file mode 100644 index 00000000000..12b8bb313c9 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -0,0 +1,195 @@ +import { type Dispatch, type SetStateAction, useCallback, useId } from 'react'; + +import type { DatePartKind } from '../utils'; +import { Segment } from './elements'; +import { + appendSegmentDigit, + getSegmentPlaceholder, + getSegmentSpinBounds, + parseSegmentNumericString, + segmentMaxLength, + SegmentValues, + spinSegment, +} from './utils'; + +export type AssignSegmentRef = ( + field: DatePartKind, + el: HTMLSpanElement | null +) => void; + +export type DatePickerInputSegmentProps = { + field: DatePartKind; + segments: SegmentValues; + disabled: boolean; + error: boolean; + onFocus: () => void; + onAltArrowDown: () => void; + /** Focus a sibling segment; must use refs registered via `assignSegmentRef` (owned by parent). */ + onSiblingFocus: (field: DatePartKind) => void; + assignSegmentRef: AssignSegmentRef; + setSegments: Dispatch>; + prevField: DatePartKind | null; + nextField: DatePartKind | null; + applySegments: (next: SegmentValues) => void; +}; + +export const DatePickerInputSegment: React.FC = ({ + field, + segments, + disabled, + error, + onFocus, + onAltArrowDown, + onSiblingFocus, + assignSegmentRef, + setSegments, + prevField, + nextField, + applySegments, +}) => { + const { min, max } = getSegmentSpinBounds({ field, segments }); + const numericValue = parseSegmentNumericString(segments[field]); + const ariaValue = + segments[field].length > 0 && numericValue != null + ? numericValue + : undefined; + const display = + segments[field].length > 0 ? segments[field] : getSegmentPlaceholder(field); + const inputID = useId(); + const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; + + const onKeyDown = useCallback( + (field: DatePartKind) => (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) { + e.preventDefault(); + e.stopPropagation(); + onAltArrowDown(); + return; + } + + if (e.key === 'ArrowLeft') { + if (prevField) { + e.preventDefault(); + onSiblingFocus(prevField); + } + return; + } + + if (e.key === 'ArrowRight') { + if (nextField) { + e.preventDefault(); + onSiblingFocus(nextField); + } + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment({ field, segments: prev, delta: 1 }), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + }); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment({ field, segments: prev, delta: -1 }), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + }); + return; + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + setSegments((prev) => { + if (prev[field].length > 0) { + const next = { + ...prev, + [field]: prev[field].slice(0, -1), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + } + if (prevField) { + queueMicrotask(() => onSiblingFocus(prevField)); + } + return prev; + }); + return; + } + + if (e.key.length === 1 && /^\d$/.test(e.key)) { + e.preventDefault(); + e.stopPropagation(); + setSegments((prev) => { + const next = { + ...prev, + [field]: appendSegmentDigit({ + field, + prev: prev[field], + digit: e.key, + }), + }; + queueMicrotask(() => { + applySegments(next); + }); + const maxLen = segmentMaxLength(field); + if (next[field].length >= maxLen && nextField) { + queueMicrotask(() => onSiblingFocus(nextField)); + } + return next; + }); + } + }, + [ + disabled, + onAltArrowDown, + prevField, + onSiblingFocus, + nextField, + setSegments, + applySegments, + ] + ); + + return ( + assignSegmentRef(field, el)} + role="spinbutton" + tabIndex={disabled ? -1 : 0} + onFocus={onFocus} + onKeyDown={onKeyDown(field)} + > + {display} + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts new file mode 100644 index 00000000000..1cfb4a3b5b1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts @@ -0,0 +1,216 @@ +import type { DateFormatLayoutItem, DatePartKind } from '../utils'; + +export type SegmentValues = { + month: string; + day: string; + year: string; +}; + +export const getDateSegmentsFromDate = (date: Date | null): SegmentValues => { + if (date === null) return { month: '', day: '', year: '' }; + return { + month: String(date.getMonth() + 1).padStart(2, '0'), + day: String(date.getDate()).padStart(2, '0'), + year: String(date.getFullYear()), + }; +}; + +export const parseSegmentsToDate = (segments: SegmentValues) => { + const { month, day, year } = segments; + if (year.length !== 4) return null; + if (month.length === 0 || day.length === 0) return null; + const monthNumber = parseInt(month, 10); + const dayNumber = parseInt(day, 10); + const yearNumber = parseInt(year, 10); + if ( + !Number.isFinite(monthNumber) || + !Number.isFinite(dayNumber) || + !Number.isFinite(yearNumber) + ) + return null; + if (monthNumber < 1 || monthNumber > 12) return null; + const parsed = new Date(yearNumber, monthNumber - 1, dayNumber); + if ( + parsed.getFullYear() !== yearNumber || + parsed.getMonth() !== monthNumber - 1 || + parsed.getDate() !== dayNumber + ) { + return null; + } + return parsed; +}; + +export const getStrictSegmentDigits = (segments: SegmentValues) => ({ + month: segments.month.replace(/\D/g, '').slice(0, 2), + day: segments.day.replace(/\D/g, '').slice(0, 2), + year: segments.year.replace(/\D/g, '').slice(0, 4), +}); + +export const isStrictlyCompleteDateEntry = (strictSegments: SegmentValues) => { + const { month, day, year } = strictSegments; + return year.length === 4 && month.length === 2 && day.length === 2; +}; + +export const normalizeSegmentValues = ( + segments: SegmentValues +): SegmentValues => { + const strictSegments = getStrictSegmentDigits(segments); + if (isStrictlyCompleteDateEntry(strictSegments)) { + const parsed = parseSegmentsToDate(strictSegments); + if (parsed) { + return getDateSegmentsFromDate(parsed); + } + return { month: '', day: '', year: '' }; + } + + const year = segments.year.replace(/\D/g, '').slice(0, 4); + let month = segments.month.replace(/\D/g, '').slice(0, 2); + let day = segments.day.replace(/\D/g, '').slice(0, 2); + + if (month.length > 0) { + const m = Math.min(12, Math.max(1, parseInt(month, 10))); + month = Number.isFinite(m) ? String(m).padStart(2, '0') : ''; + } + if (year.length === 4 && month.length === 2 && day.length > 0) { + const y = parseInt(year, 10); + const m = parseInt(month, 10); + const dmax = new Date(y, m, 0).getDate(); + const d = Math.min(dmax, Math.max(1, parseInt(day, 10))); + day = Number.isFinite(d) ? String(d).padStart(2, '0') : ''; + } else if (day.length > 0) { + const d = Math.min(31, Math.max(1, parseInt(day, 10))); + day = Number.isFinite(d) ? String(d).padStart(2, '0') : ''; + } + return { month, day, year }; +}; + +export const getSegmentPlaceholder = (field: DatePartKind) => + field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD'; + +export const segmentMaxLength = (field: DatePartKind) => + field === 'year' ? 4 : 2; + +export const getSegmentSpinBounds = ({ + field, + segments, +}: { + field: DatePartKind; + segments: SegmentValues; +}): { min: number; max: number } => { + switch (field) { + case 'month': + return { min: 1, max: 12 }; + case 'day': { + const year = + segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; + const month = + segments.month.length >= 1 + ? Math.min(12, Math.max(1, parseInt(segments.month, 10) || 1)) + : 1; + const maxDay = new Date(year, month, 0).getDate(); + return { min: 1, max: Number.isFinite(maxDay) ? maxDay : 31 }; + } + case 'year': + return { min: 1, max: 9999 }; + default: + return { min: 1, max: 9999 }; + } +}; + +export const parseSegmentNumericString = (str: string) => { + const digits = str.replace(/\D/g, ''); + if (digits.length === 0) return null; + const numericValue = parseInt(digits, 10); + return Number.isFinite(numericValue) ? numericValue : null; +}; + +export const padSegmentNumber = ({ + field, + numericValue, +}: { + field: DatePartKind; + numericValue: number; +}) => { + if (field === 'year') { + const clamped = Math.min(9999, Math.max(1, numericValue)); + return String(clamped).padStart(4, '0'); + } + const clamped = Math.min(99, Math.max(0, numericValue)); + return String(clamped).padStart(2, '0').slice(-2); +}; + +export const appendSegmentDigit = ({ + field, + prev, + digit, +}: { + field: DatePartKind; + prev: string; + digit: string; +}) => { + if (!/^\d$/.test(digit)) return prev; + const maxLen = segmentMaxLength(field); + const digitsOnly = prev.replace(/\D/g, ''); + // If full, appending would truncate to the same value — use the new digit as a fresh start. + if (digitsOnly.length >= maxLen) { + return digit.slice(0, maxLen); + } + return (digitsOnly + digit).slice(0, maxLen); +}; + +export const spinSegment = ({ + field, + segments, + delta, +}: { + field: DatePartKind; + segments: SegmentValues; + delta: 1 | -1; +}) => { + const { min, max } = getSegmentSpinBounds({ field, segments }); + let currentSegementValue = parseSegmentNumericString(segments[field]); + + if (currentSegementValue === null) { + currentSegementValue = + field === 'year' + ? delta > 0 + ? new Date().getFullYear() + : max + : delta > 0 + ? min + : max; + } else { + currentSegementValue += delta; + } + + currentSegementValue = Math.min(max, Math.max(min, currentSegementValue)); + return padSegmentNumber({ field, numericValue: currentSegementValue }); +}; + +export const buildCombinedFromSegments = ({ + segments, + layout, +}: { + segments: SegmentValues; + layout: DateFormatLayoutItem[]; +}) => + layout + .map((item) => (item.kind === 'literal' ? item.text : segments[item.field])) + .join(''); + +export const digitsToSegments = ({ + digits, + fieldOrder, +}: { + digits: string; + fieldOrder: DatePartKind[]; +}): SegmentValues => { + let rest = digits; + const segments: SegmentValues = { month: '', day: '', year: '' }; + for (const field of fieldOrder) { + const maxLen = field === 'year' ? 4 : 2; + segments[field] = rest.slice(0, maxLen); + rest = rest.slice(maxLen); + } + return segments; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx new file mode 100644 index 00000000000..901d25d5916 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx @@ -0,0 +1,171 @@ +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps, FC } from 'react'; + +import { DatePickerProvider } from '../../DatePickerContext'; +import { + createMockRangeContext, + createMockSingleContext, +} from '../../DatePickerContext/__tests__/mockContexts'; +import type { DatePickerContextValue } from '../../DatePickerContext/types'; +import { DatePickerInput } from '../index'; + +type HarnessProps = { context: DatePickerContextValue } & ComponentProps< + typeof DatePickerInput +>; + +const DatePickerInputHarness: FC = ({ + context, + ...inputProps +}) => ( + + + +); + +const renderInput = setupRtl(DatePickerInputHarness, { + context: createMockSingleContext(), +}); + +type RangeLabelsHarnessProps = { + startLabel?: string; + endLabel?: string; +}; + +const RangeLabelsHarness: FC = ({ + startLabel, + endLabel, +}) => { + const context = createMockRangeContext(); + return ( + + + + + ); +}; + +const renderRange = setupRtl(RangeLabelsHarness, {}); + +describe('DatePickerInput', () => { + it('throws when rendered without DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow(/useDatePickerContext must be used within a DatePicker/); + }); + + it('calls openCalendar when the shell is clicked', async () => { + const user = userEvent.setup(); + const openCalendar = jest.fn(); + const { view } = renderInput({ + context: createMockSingleContext({ openCalendar, isCalendarOpen: false }), + }); + + await user.click(view.getByRole('group')); + + expect(openCalendar).toHaveBeenCalledTimes(1); + }); + + it('renders default Date label in single date mode', () => { + const { view } = renderInput(); + + view.getByText('Date'); + }); + + it('renders default Start date and End date labels in range mode', () => { + const { view } = renderRange(); + + view.getByText('Start date'); + view.getByText('End date'); + }); + + it('renders a custom label when provided in single date mode', () => { + const { view } = renderInput({ label: 'Ship date' }); + + view.getByText('Ship date'); + }); + + it('renders a custom label when provided in range mode', () => { + const { view } = renderRange({ + startLabel: 'The Beginning', + endLabel: 'The End', + }); + + view.getByText('The Beginning'); + view.getByText('The End'); + }); + + it('syncs hidden input to the context selected date (ISO date-only)', () => { + const { view } = renderInput(); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); + + it('moves focus between segments with ArrowLeft and ArrowRight', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(day); + + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(year); + + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(day); + }); + + it('updates the hidden input when a full date is typed (auto-advance between segments)', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('03'); + expect(document.activeElement).toBe(day); + await user.keyboard('15'); + expect(document.activeElement).toBe(year); + await user.keyboard('2024'); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); + + it('normalizes and keeps a valid date after blur', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('03'); + await user.keyboard('15'); + await user.keyboard('2024'); + + fireEvent.blur(year, { relatedTarget: document.body }); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx new file mode 100644 index 00000000000..672f95d6b7f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -0,0 +1,46 @@ +import { variant } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { FlexBox } from '../../Box'; +import { + formFieldFocusStyles, + formFieldStyles, + inputSizeStyles, +} from '../../Form/styles'; + +const shellFocusStyles = variant({ + variants: { + error: { + borderColor: 'feedback-error', + '&:hover': { + borderColor: 'feedback-error', + }, + '&:focus': { + borderColor: 'feedback-error', + boxShadow: `inset 0 0 0 1px feedback-error`, + }, + '&:focus-within': { + borderColor: 'feedback-error', + boxShadow: `inset 0 0 0 1px feedback-error`, + }, + }, + default: { + '&:focus-within': formFieldFocusStyles, + }, + }, +}); + +interface SegmentedShellProps + extends StyleProps, + StyleProps {} + +/** + * Shell uses the same styles as `Input`. `formFieldStyles` targets `&:focus`, but the host is a + * `div` — focus is on inner spinbuttons, so we mirror `Input` focus visuals with `&:focus-within`. + */ +export const SegmentedShell = styled(FlexBox)( + formFieldStyles, + inputSizeStyles, + shellFocusStyles +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx new file mode 100644 index 00000000000..f8a48630f91 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -0,0 +1,308 @@ +import { MiniCalendarIcon } from '@codecademy/gamut-icons'; +import { + type FocusEvent, + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { FlexBox } from '../../Box'; +import { FormGroup } from '../../Form/elements/FormGroup'; +import type { InputWrapperProps } from '../../Form/inputs/Input'; +import { isSameDay } from '../DatePickerCalendar/Calendar/utils/dateGrid'; +import { handleDateSelectRange } from '../DatePickerCalendar/utils/dateSelect'; +import { useDatePicker } from '../DatePickerContext'; +import { SegmentedShell } from './elements'; +import { DatePickerInputSegment } from './Segment'; +import { SegmentLiteral } from './Segment/elements'; +import { + getDateSegmentsFromDate, + normalizeSegmentValues, + parseSegmentsToDate, + type SegmentValues, +} from './Segment/utils'; +import { + type DatePartKind, + formatDateISO8601DateOnly, + getDateFieldOrder, + getDateFormatLayout, +} from './utils'; + +export type DatePickerInputProps = Omit< + InputWrapperProps, + 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' +> & { + /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ + rangePart?: 'start' | 'end'; +}; + +export const DatePickerInput = forwardRef( + ( + { disabled, error, form, label, name, rangePart, size = 'base', ...rest }, + ref + ) => { + const context = useDatePicker(); + + if (context === null) { + throw new Error( + 'DatePickerInput must be used inside a DatePicker (it reads shared state from context).' + ); + } + + const { + mode, + openCalendar, + focusCalendar, + locale, + isCalendarOpen, + translations, + disableDate, + } = context; + + const isRange = mode === 'range'; + const endDate = isRange ? context.endDate : null; + const date = isRange ? context.startDate : context.selectedDate; + + const inputID = useId(); + const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; + + const { layout, fieldOrder } = useMemo(() => { + const layout = getDateFormatLayout(locale); + return { layout, fieldOrder: getDateFieldOrder(layout) }; + }, [locale]); + + const defaultLabel = !isRange + ? translations.dateLabel + : rangePart === 'end' + ? translations.endDateLabel + : translations.startDateLabel; + + const boundDate = isRange && rangePart === 'end' ? endDate : date; + const segmentsFromBound = useMemo( + () => getDateSegmentsFromDate(boundDate), + [boundDate] + ); + const [segments, setSegments] = useState(segmentsFromBound); + + const parsedForHidden = parseSegmentsToDate(segments); + const hiddenValue = parsedForHidden + ? formatDateISO8601DateOnly(parsedForHidden) + : ''; + + const isInputFocusedRef = useRef(false); + const containerRef = useRef(null); + const segmentElRefs = useRef< + Partial> + >({}); + + const assignSegmentRef = useCallback( + (field: DatePartKind, el: HTMLSpanElement | null) => { + segmentElRefs.current[field] = el; + }, + [segmentElRefs] + ); + + const onSiblingSegmentFocus = useCallback( + (field: DatePartKind) => { + segmentElRefs.current[field]?.focus(); + }, + [segmentElRefs] + ); + + const shellRef = useCallback( + (el: HTMLDivElement | null) => { + containerRef.current = el; + if (typeof ref === 'function') ref(el); + else if (ref != null) ref.current = el; + }, + [ref] + ); + + useEffect(() => { + if (!isInputFocusedRef.current) { + setSegments(segmentsFromBound); + } + }, [segmentsFromBound]); + + const commitParsedDate = useCallback( + (parsed: Date) => { + if (!isRange) { + context.onSelection(parsed); + } + if (isRange && rangePart) { + handleDateSelectRange({ + date: parsed, + activeRangePart: rangePart, + startDate: date, + endDate, + onRangeSelection: context.onRangeSelection, + disableDate, + }); + } + }, + [isRange, rangePart, context, endDate, date, disableDate] + ); + + const clearSelection = useCallback(() => { + if (!isRange) { + context.onSelection(null); + } + if (isRange && rangePart) { + if (rangePart === 'start') context.onRangeSelection(null, endDate); + else context.onRangeSelection(date, null); + } + }, [isRange, rangePart, context, endDate, date]); + + const onSegmentChange = useCallback( + (next: SegmentValues) => { + const parsed = parseSegmentsToDate(next); + if (parsed) commitParsedDate(parsed); + else if (!next.month && !next.day && !next.year) clearSelection(); + }, + [clearSelection, commitParsedDate] + ); + + const onContainerBlur = useCallback( + (e: FocusEvent) => { + if (containerRef.current?.contains(e.relatedTarget as Node)) return; + isInputFocusedRef.current = false; + setSegments((prev) => { + const normalized = normalizeSegmentValues(prev); + const parsed = parseSegmentsToDate(normalized); + if (parsed) { + const sameAsBound = isSameDay(parsed, boundDate); + if (isCalendarOpen && !sameAsBound) { + queueMicrotask(() => { + commitParsedDate(parsed); + }); + } + return normalized; + } + if (!normalized.month && !normalized.day && !normalized.year) { + queueMicrotask(() => { + clearSelection(); + }); + return getDateSegmentsFromDate(null); + } + return segmentsFromBound; + }); + }, + [ + containerRef, + boundDate, + segmentsFromBound, + clearSelection, + commitParsedDate, + isCalendarOpen, + ] + ); + + const setActiveRangePartForField = useCallback(() => { + if (isRange && rangePart) context.setActiveRangePart(rangePart); + }, [isRange, rangePart, context]); + + const onSegmentFocus = useCallback(() => { + isInputFocusedRef.current = true; + setActiveRangePartForField(); + }, [isInputFocusedRef, setActiveRangePartForField]); + + const onShellFocus = useCallback(() => { + setActiveRangePartForField(); + }, [setActiveRangePartForField]); + + const onShellClick = useCallback(() => { + if (disabled) return; + setActiveRangePartForField(); + openCalendar(); + }, [disabled, setActiveRangePartForField, openCalendar]); + + const onSegmentAltArrowDown = useCallback(() => { + if (!isCalendarOpen) openCalendar(); + focusCalendar(); + }, [isCalendarOpen, openCalendar, focusCalendar]); + + return ( + + + + {layout.map((item, index) => { + if (item.kind === 'literal') { + return ( + + {`${item.text}`} + + ); + } + const idx = fieldOrder.indexOf(item.field); + const prevField = idx > 0 ? fieldOrder[idx - 1] : null; + const nextField = + idx < fieldOrder.length - 1 ? fieldOrder[idx + 1] : null; + + return ( + + ); + })} + + + + + + + + ); + } +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/utils.ts new file mode 100644 index 00000000000..aaa7debf00c --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils.ts @@ -0,0 +1,44 @@ +import { stringifyLocale } from '../utils/locale'; + +export type DatePartKind = 'month' | 'day' | 'year'; + +export type DateFormatLayoutItem = + | { kind: 'field'; field: DatePartKind } + | { kind: 'literal'; text: string }; + +export const getDateFormatLayout = (locale: Intl.Locale) => { + const parts = new Intl.DateTimeFormat(stringifyLocale(locale), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(2025, 10, 15)); + + const items: DateFormatLayoutItem[] = []; + for (const part of parts) { + if (part.type === 'month') items.push({ kind: 'field', field: 'month' }); + else if (part.type === 'day') items.push({ kind: 'field', field: 'day' }); + else if (part.type === 'year') items.push({ kind: 'field', field: 'year' }); + else if (part.type === 'literal') + items.push({ kind: 'literal', text: part.value }); + } + return items; +}; + +export const getDateFieldOrder = (layout: DateFormatLayoutItem[]) => { + const order: DatePartKind[] = []; + for (const item of layout) { + if (item.kind === 'field' && !order.includes(item.field)) { + order.push(item.field); + } + } + return order.length === 3 + ? order + : (['month', 'day', 'year'] as DatePartKind[]); +}; + +export const formatDateISO8601DateOnly = (date: Date) => { + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const d = date.getDate(); + return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; +}; diff --git a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx new file mode 100644 index 00000000000..3e6c5195373 --- /dev/null +++ b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx @@ -0,0 +1,345 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FC, FormEvent, FormEventHandler } from 'react'; + +import { DatePicker } from '../DatePicker'; +import type { DatePickerTranslations } from '../utils/translations'; + +jest.mock('react-use', () => { + const actual = jest.requireActual('react-use'); + return { + ...actual, + useMedia: jest.fn(() => false), + }; +}); + +type SingleHarnessProps = { + selectedDate: Date | null; + onSelected: (date: Date | null) => void; + translations?: DatePickerTranslations; +}; + +const SingleHarness: FC = ({ + selectedDate, + onSelected, + translations, +}) => ( + +); + +const renderSingle = setupRtl(SingleHarness, { + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), +}); + +type RangeHarnessProps = { + startDate: Date | null; + endDate: Date | null; + onStartSelected: (date: Date | null) => void; + onEndSelected: (date: Date | null) => void; +}; + +const RangeHarness: FC = ({ + startDate, + endDate, + onStartSelected, + onEndSelected, +}) => ( + +); + +const renderRange = setupRtl(RangeHarness, { + startDate: null, + endDate: null, + onStartSelected: jest.fn(), + onEndSelected: jest.fn(), +}); + +const composedSetSelected = jest.fn(); + +const ComposedOnlyHarness: FC = () => ( + +
composed
+
+); + +const renderComposedOnly = setupRtl(ComposedOnlyHarness, {}); + +type SingleInFormProps = { + onFormSubmit: FormEventHandler; +} & SingleHarnessProps; + +const SingleInForm: FC = ({ + onFormSubmit, + ...harnessProps +}) => ( +
+ + +); + +const renderSingleInForm = setupRtl(SingleInForm, { + onFormSubmit: (e: FormEvent) => { + e.preventDefault(); + }, + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), +}); + +type RangeInFormProps = { + onFormSubmit: FormEventHandler; +} & RangeHarnessProps; + +const RangeInForm: FC = ({ onFormSubmit, ...rest }) => ( +
+ + +); + +const renderRangeInForm = setupRtl(RangeInForm, { + onFormSubmit: (e: FormEvent) => { + e.preventDefault(); + }, + startDate: null, + endDate: null, + onStartSelected: jest.fn(), + onEndSelected: jest.fn(), +}); + +describe('DatePicker', () => { + it('does not show the calendar dialog until the input group opens it', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(view.getByRole('group')); + + expect(view.getByRole('dialog')).toBeVisible(); + }); + + it('renders a custom date label when translations override dateLabel', () => { + const { view } = renderSingle({ + translations: { dateLabel: 'Ship date' }, + }); + + view.getByText('Ship date'); + }); + + it('renders two input groups in range mode', () => { + const { view } = renderRange(); + + expect(view.getAllByRole('group')).toHaveLength(2); + }); + + it('associates the field label with the segment shell via label `for` and shell `id` (DatePickerInput)', () => { + const { view } = renderSingle(); + const shell = view.getByRole('group'); + const shellId = shell.getAttribute('id'); + expect(shellId).toBeTruthy(); + expect( + view.container.querySelector(`label[for="${shellId}"]`) + ).toBeInTheDocument(); + }); + + it('renders only children when the children prop is provided', () => { + const { view } = renderComposedOnly(); + + expect(view.getByTestId('composed')).toHaveTextContent('composed'); + expect(view.queryByText('Date')).not.toBeInTheDocument(); + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes the calendar on Escape from the input when the popover is open', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + + const spinbutton = view.getByRole('spinbutton', { name: 'month' }); + spinbutton.focus(); + await user.keyboard('{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes the calendar on Escape from a month nav chevron (not only the day grid)', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + + view.getByRole('button', { name: 'Last month' }).focus(); + await user.keyboard('{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +describe('DatePicker inside a form', () => { + it('renders the field inside the form and can open the dialog', async () => { + const user = userEvent.setup(); + const { view } = renderSingleInForm(); + + const form = view.getByTestId('datepicker-form'); + expect(form).toBeInstanceOf(HTMLFormElement); + expect(form.querySelector('[role="group"]')).toBeInTheDocument(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + }); + + it('does not submit the form when Escape closes the calendar from the field', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + view.getByRole('spinbutton', { name: 'month' }).focus(); + await user.keyboard('{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit the form when Escape closes the calendar from a month chevron', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + view.getByRole('button', { name: 'Last month' }).focus(); + await user.keyboard('{Escape}'); + + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit the form when selecting a day in the grid', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + await user.click(view.getByRole('gridcell', { name: /March 20, 2024/i })); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('still submits the form when a real submit control is used', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + + const SingleInFormWithSubmit: FC = ({ + onFormSubmit: onSubmit, + ...harness + }) => ( +
+ + + + ); + + const renderWithButton = setupRtl(SingleInFormWithSubmit, { + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), + }); + + renderWithButton({ + onFormSubmit: (e) => { + e.preventDefault(); + onFormSubmit(e); + }, + }); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('exposes a hidden field whose name and value match FormData on submit (default DatePicker passes `name` through)', () => { + const onFormSubmit = jest.fn((e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const hidden = form.querySelector( + 'input[type="hidden"]' + )!; + expect(hidden.name).toBeTruthy(); + expect(hidden).toHaveValue('2024-03-01'); + expect(new FormData(form).get(hidden.name)).toBe(hidden.value); + }); + + const { view } = renderSingleInForm({ onFormSubmit }); + fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('sends the selected date as the ISO 8601 value in FormData on submit', () => { + const selected = new Date(2024, 5, 15); + const onFormSubmit = jest.fn((e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const hidden = form.querySelector( + 'input[type="hidden"]' + )!; + const key = hidden.name; + const fromFormData = new FormData(form).get(key); + expect(fromFormData).toBe('2024-06-15'); + expect(fromFormData).toBe(hidden.value); + }); + + const { view } = renderSingleInForm({ + onFormSubmit, + selectedDate: selected, + }); + fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('in range mode, has two named hidden fields for start and end', () => { + const { view } = renderRangeInForm(); + const hiddens = view.container.querySelectorAll('input[type="hidden"]'); + expect(hiddens).toHaveLength(2); + const start = hiddens[0] as HTMLInputElement; + const end = hiddens[1] as HTMLInputElement; + expect(start.name).toBeTruthy(); + expect(end.name).toBeTruthy(); + expect(start.name).not.toBe(end.name); + }); + + it('in range mode, does not submit the form when opening and closing the calendar on the start field', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderRangeInForm({ onFormSubmit }); + + const groups = view.getAllByRole('group'); + expect(groups).toHaveLength(2); + await user.click(groups[0]!); + expect(view.getByRole('dialog')).toBeVisible(); + + view.getByRole('button', { name: 'Next month' }).focus(); + await user.keyboard('{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx new file mode 100644 index 00000000000..53bcb00c74d --- /dev/null +++ b/packages/gamut/src/DatePicker/index.tsx @@ -0,0 +1,6 @@ +export * from './DatePicker'; +export * from './DatePickerContext'; +export * from './DatePickerCalendar'; +export * from './DatePickerInput'; +export * from './types'; +export { matchDisabledDates } from './DatePickerCalendar/Calendar/utils/dateGrid'; diff --git a/packages/gamut/src/DatePicker/sharedTypes.ts b/packages/gamut/src/DatePicker/sharedTypes.ts new file mode 100644 index 00000000000..00cbd62acb7 --- /dev/null +++ b/packages/gamut/src/DatePicker/sharedTypes.ts @@ -0,0 +1,41 @@ +export interface DatePickerSharedProps { + /** + * Return `true` to disable that calendar day. Use `matchDisabledDates` from `./utils/dateGrid` + * to disable a fixed list of days. + * + * @example Disable anything older than three calendar months + * ```tsx + * const cutoff = new Date(); + * cutoff.setMonth(cutoff.getMonth() - 3); + * const startOfCutoff = new Date( + * cutoff.getFullYear(), + * cutoff.getMonth(), + * cutoff.getDate() + * ); + * {}} + * disableDate={(d) => d < startOfCutoff} + * /> + * ``` + */ + disableDate?: (date: Date) => boolean; + + /** + * Locale for formatting and `Intl.Locale` APIs. Accepts `Intl.LocalesArgument` (e.g. `'en-US'`, + * `['en-GB', 'en']`, or a prebuilt `Intl.Locale`). Omitted → runtime default (user agent). + */ + locale?: Intl.LocalesArgument; +} + +export interface CalendarQuickAction { + /** Number of days, weeks, months, or years to add or subtract from the current date. */ + num: number; + /** Time period to add or subtract from the current date. */ + timePeriod: 'day' | 'week' | 'month' | 'year'; + /** Text to display for the quick action. */ + displayText: string; + /** Callback when the quick action is clicked. */ + onClick?: () => void; +} diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts new file mode 100644 index 00000000000..c35cf19d353 --- /dev/null +++ b/packages/gamut/src/DatePicker/types.ts @@ -0,0 +1,169 @@ +import { ComponentProps } from 'react'; + +import { Input } from '../Form/inputs/Input'; +import { CalendarQuickAction, DatePickerSharedProps } from './sharedTypes'; +import { DatePickerTranslations } from './utils/translations'; + +interface DatePickerBaseProps + extends DatePickerSharedProps { + /** Discriminator: set to `"single"` or `"range"` to determine the mode of the DatePicker */ + mode: Mode; + /** When provided, only the provider is rendered and children compose Input + Calendar. */ + children?: React.ReactNode; + /** Override default UI strings for internationalization. + * + * @default DEFAULT_DATE_PICKER_TRANSLATIONS + * @see {@link DatePickerTranslations} for the shape of the translations and default values + * @example + * ```tsx + * + * ``` + */ + translations?: DatePickerTranslations; + /** Size of the input. + * @default "base" + * @see `size` on {@link Input} + */ + inputSize?: ComponentProps['size']; + /** + * Calendar footer quick actions. Default values are provided based on the mode, but you can pass your own. Only the first 3 quick actions will be displayed. + * Pass `null` to omit quick actions. + * + * @default for single mode: Yesterday, Today, Tomorrow + * for range mode: Last 7 days, Last 30 days, Last 90 days + * + * @see {@link CalendarQuickAction} for the shape of the quick actions. + * + * @example single mode: + * ```tsx + * + * ``` + * @example range mode: + * ```tsx + * { + /** Controlled selected date. Pass `null` to not have a default selected date. Pass a `Date` to have a default selected date. + * + * @example + * ```tsx + * const [selectedDate, setSelectedDate] = useState(null); + * + * ``` + */ + selectedDate: Date | null; + /** Callback called when the user selects a date. Pass the new date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [selectedDate, setSelectedDate] = useState(null); + * + * ``` + */ + onSelected: (date: Date | null) => void; +} + +export interface DatePickerRangeProps extends DatePickerBaseProps<'range'> { + /** Controlled start date. Pass `null` to not have a default start date. Pass a `Date` to have a default start date. + * + * @example + * ```tsx + * const [startDate, setStartDate] = useState(null); + * const [endDate, setEndDate] = useState(null); + * + * + * ``` + */ + startDate: Date | null; + /** Controlled end date. Pass `null` to not have a default end date. Pass a `Date` to have a default end date. + * + * @example + * ```tsx + * const [endDate, setEndDate] = useState(null); + * const [startDate, setStartDate] = useState(null); + * + * ``` + */ + endDate: Date | null; + /** Callback called when the user changes the start date. Pass the new start date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [startDate, setStartDate] = useState(null); + * const [endDate, setEndDate] = useState(null); + * + * ``` + */ + onStartSelected: (date: Date | null) => void; + /** Callback called when the user changes the end date. Pass the new end date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [endDate, setEndDate] = useState(null); + * const [startDate, setStartDate] = useState(null); + * + * ``` + */ + onEndSelected: (date: Date | null) => void; +} + +export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; diff --git a/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts new file mode 100644 index 00000000000..26d6e765b37 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts @@ -0,0 +1,72 @@ +import { + getDefaultLocaleTag, + getIsoFirstDayFromLocale, + resolveLocale, + stringifyLocale, +} from '../locale'; + +describe('getDefaultLocaleTag', () => { + it('returns a non-empty BCP 47 tag from the runtime', () => { + const tag = getDefaultLocaleTag(); + expect(typeof tag).toBe('string'); + expect(tag.length).toBeGreaterThan(0); + }); +}); + +describe('resolveLocale', () => { + it('uses the runtime default when locales is undefined', () => { + const loc = resolveLocale(undefined); + expect(loc).toBeInstanceOf(Intl.Locale); + expect(loc.toString()).toBe(getDefaultLocaleTag()); + }); + + it('returns the same Intl.Locale instance when passed in', () => { + const input = new Intl.Locale('en-CA'); + expect(resolveLocale(input)).toBe(input); + }); + + it('parses a string tag', () => { + const loc = resolveLocale('de-DE'); + expect(loc.toString()).toBe('de-DE'); + }); + + it('uses the first entry of a locale array', () => { + const loc = resolveLocale(['fr-FR', 'fr']); + expect(loc.toString()).toBe('fr-FR'); + }); + + it('falls back to default when the locale array is empty', () => { + const loc = resolveLocale([]); + expect(loc.toString()).toBe(getDefaultLocaleTag()); + }); +}); + +describe('stringifyLocale', () => { + it('returns locale.toString()', () => { + const loc = new Intl.Locale('en-US'); + expect(stringifyLocale(loc)).toBe(loc.toString()); + }); +}); + +describe('getIsoFirstDayFromLocale', () => { + it('returns the override when provided', () => { + const locale = new Intl.Locale('en-US'); + expect(getIsoFirstDayFromLocale(locale, 1)).toBe(1); + expect(getIsoFirstDayFromLocale(locale, 7)).toBe(7); + }); + + it('uses getWeekInfo().firstDay when it is between 1 and 7', () => { + const locale = { + getWeekInfo: () => ({ firstDay: 3 }), + } as unknown as Intl.Locale; + expect(getIsoFirstDayFromLocale(locale)).toBe(3); + }); + + it('falls back to Sunday (7) when getWeekInfo is missing or firstDay is invalid', () => { + expect(getIsoFirstDayFromLocale({} as unknown as Intl.Locale)).toBe(7); + const badFirstDay = { + getWeekInfo: () => ({ firstDay: 0 }), + } as unknown as Intl.Locale; + expect(getIsoFirstDayFromLocale(badFirstDay)).toBe(7); + }); +}); diff --git a/packages/gamut/src/DatePicker/utils/locale.ts b/packages/gamut/src/DatePicker/utils/locale.ts new file mode 100644 index 00000000000..b0cfb94f66a --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/locale.ts @@ -0,0 +1,98 @@ +// Replaces `Intl.Locale` when missing or incomplete (e.g. no `getWeekInfo` in Firefox). +// https://formatjs.github.io/docs/polyfills/intl-locale/ +import '@formatjs/intl-locale/polyfill.js'; + +import { useEffect, useMemo, useState } from 'react'; + +/** + * The runtime default locale string (user agent), matching what `Intl` uses when no locale is passed. + */ +export const getDefaultLocaleTag = () => + new Intl.DateTimeFormat().resolvedOptions().locale; + +/** + * Resolves `Intl.LocalesArgument` (or `undefined`) to a stable `Intl.Locale` instance for formatting + * and locale metadata. + * + * - `undefined` → default runtime locale via {@link getDefaultLocaleTag} + * - `Intl.Locale` → returned as-is (no duplicate allocation) + * - `string` / `readonly string[]` → `new Intl.Locale(...)` + */ +export const resolveLocale = (locales?: Intl.LocalesArgument) => { + if (locales === undefined) { + return new Intl.Locale(getDefaultLocaleTag()); + } + if (locales instanceof Intl.Locale) { + return locales; + } + if (typeof locales === 'string') { + return new Intl.Locale(locales); + } + const first = locales[0]; + if (first === undefined) { + return new Intl.Locale(getDefaultLocaleTag()); + } + if (typeof first === 'string') { + return new Intl.Locale(first); + } + return first instanceof Intl.Locale ? first : new Intl.Locale(String(first)); +}; + +export const useResolvedLocale = (locale?: Intl.LocalesArgument) => + useMemo(() => resolveLocale(locale), [locale]); + +export const stringifyLocale = (locale: Intl.Locale) => locale.toString(); + +/** ISO weekday: 1 = Monday … 7 = Sunday (matches `Intl.Locale#getWeekInfo().firstDay`). */ +export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +/** `getWeekInfo` is stage-3; typings may lag behind runtime / polyfill. */ +type LocaleWithWeekInfo = Intl.Locale & { + getWeekInfo?: () => { firstDay: number }; +}; + +/** + * First calendar column weekday from `locale` (via `getWeekInfo()`), or explicit override. + */ +export const getIsoFirstDayFromLocale = ( + locale: Intl.Locale, + weekStartsOnOverride?: IsoWeekday +) => { + if (weekStartsOnOverride) return weekStartsOnOverride; + + try { + const getWeekInfo = (locale as LocaleWithWeekInfo).getWeekInfo?.bind( + locale + ); + if (typeof getWeekInfo === 'function') { + const { firstDay } = getWeekInfo(); + if (typeof firstDay === 'number' && firstDay >= 1 && firstDay <= 7) { + return firstDay as IsoWeekday; + } + } + } catch {} + return 7; +}; + +/** + * Resolved first weekday for the calendar grid. Re-reads after mount so async polyfills + * (e.g. Firefox) can install `getWeekInfo` before the first paint in some bundles. + */ +export const useIsoFirstWeekday = ( + locale: Intl.Locale, + weekStartsOnOverride?: IsoWeekday +) => { + const [firstDay, setFirstDay] = useState(() => + getIsoFirstDayFromLocale(locale, weekStartsOnOverride) + ); + + useEffect(() => { + setFirstDay(getIsoFirstDayFromLocale(locale, weekStartsOnOverride)); + const t = setTimeout(() => { + setFirstDay(getIsoFirstDayFromLocale(locale, weekStartsOnOverride)); + }, 0); + return () => clearTimeout(t); + }, [locale, weekStartsOnOverride]); + + return firstDay; +}; diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts new file mode 100644 index 00000000000..ba5dceaa0e7 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -0,0 +1,30 @@ +export interface DatePickerTranslations { + /** Label for the clear date button (default: "Clear"). */ + clearButtonText?: string; + /** Label for the date input in single mode (default: "Date"). */ + dateLabel?: string; + /** Label for the start date input in range mode (default: "Start date"). */ + startDateLabel?: string; + /** Label for the end date input in range mode (default: "End date"). */ + endDateLabel?: string; + /** aria-label for the calendar dialog (default: "Choose date"). */ + calendarDialogAriaLabel?: string; + /** Label for the last 7 days quick action (default: "Last 7 days"). */ + last7DaysDisplayText?: string; + /** Label for the last 30 days quick action (default: "Last 30 days"). */ + last30DaysDisplayText?: string; + /** Label for the last 90 days quick action (default: "Last 90 days"). */ + last90DaysDisplayText?: string; +} + +export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = + { + clearButtonText: 'Clear', + dateLabel: 'Date', + startDateLabel: 'Start date', + endDateLabel: 'End date', + calendarDialogAriaLabel: 'Choose date', + last7DaysDisplayText: 'Last 7 days', + last30DaysDisplayText: 'Last 30 days', + last90DaysDisplayText: 'Last 90 days', + }; diff --git a/packages/gamut/src/FocusTrap/index.tsx b/packages/gamut/src/FocusTrap/index.tsx index 94efeebd9d7..2d7174b2e02 100644 --- a/packages/gamut/src/FocusTrap/index.tsx +++ b/packages/gamut/src/FocusTrap/index.tsx @@ -33,9 +33,9 @@ export interface FocusTrapProps extends WithChildrenProp { */ allowPageInteraction?: boolean; /** - * Passthrough for react-focus-on library props + * Passthrough for react-focus-on library props (partial; only override what you need). */ - focusOnProps?: ReactFocusOnProps; + focusOnProps?: Partial; } export const FocusTrap: React.FC = ({ diff --git a/packages/gamut/src/Form/inputs/Input.tsx b/packages/gamut/src/Form/inputs/Input.tsx index 3f6b176e65b..974b774fe4c 100644 --- a/packages/gamut/src/Form/inputs/Input.tsx +++ b/packages/gamut/src/Form/inputs/Input.tsx @@ -77,7 +77,7 @@ const InputElement = styled.input( inputSizeStyles, (props) => css({ - paddingRight: props.icon ? `2.3rem` : `initial`, + pr: props.icon ? (`2.3rem` as any) : `initial`, textIndent: 0, }) ); diff --git a/packages/gamut/src/InternalFloatingCard/InternalFloatingCard.tsx b/packages/gamut/src/InternalFloatingCard/InternalFloatingCard.tsx deleted file mode 100644 index 69ac171349e..00000000000 --- a/packages/gamut/src/InternalFloatingCard/InternalFloatingCard.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { CheckerDense, PatternProps } from '@codecademy/gamut-patterns'; -import { styledOptions, system } from '@codecademy/gamut-styles'; -import { StyleProps, variance } from '@codecademy/variance'; -import styled from '@emotion/styled'; -import { Properties } from 'csstype'; -import { ComponentProps, forwardRef } from 'react'; -import * as React from 'react'; - -import { Box } from '../Box'; - -const cardProps = variance.compose(system.layout, system.padding); - -const beakVariants = system.variant({ - prop: 'beak', - base: { - p: 12, - '&:after': { - content: '""', - width: '1.25rem', - height: '1.25rem', - bg: 'inherit', - transform: 'rotate(45deg)', - position: 'absolute', - border: 1, - borderStyleRight: 'none', - borderStyleBottom: 'none', - }, - }, - variants: { - 'bottom-left': { - '&:after': { - bottom: 'calc(-0.625rem - 1px)', - left: '1.5rem', - transform: 'rotate(225deg)', - }, - }, - 'bottom-right': { - '&:after': { - bottom: 'calc(-0.625rem - 1px)', - right: '1.5rem', - transform: 'rotate(225deg)', - }, - }, - 'top-left': { - '&:after': { - top: 'calc(-0.625rem - 1px)', - left: '1.5rem', - transform: 'rotate(45deg)', - }, - }, - 'top-right': { - '&:after': { - top: 'calc(-0.625rem - 1px)', - right: '1.5rem', - transform: 'rotate(45deg)', - }, - }, - }, -}); - -const CardBody = styled('div', styledOptions)< - StyleProps & StyleProps ->( - system.css({ - p: 12, - zIndex: 1, - position: 'relative', - bg: 'background', - border: 1, - maxWidth: 1, - }), - beakVariants, - cardProps -); - -type InternalFloatingCardWrapper = { - containerDisplay?: Properties['display']; -}; - -export type InternalFloatingCardProps = { - className?: string; - pattern?: React.ComponentType; - shadow?: 'bottomLeft' | 'bottomRight'; -} & ComponentProps; - -export type InternalFloatingCardWithWrapper = InternalFloatingCardProps & - InternalFloatingCardWrapper; - -/** - * @deprecated - * This component is strictly for internal Gamut usage. - * Please use the `Card` component instead. - */ -export const InternalFloatingCard = forwardRef< - HTMLDivElement, - InternalFloatingCardWithWrapper ->( - ( - { - children, - className, - pattern: Pattern = CheckerDense, - shadow = 'bottomLeft', - containerDisplay = 'inline-block', - ...rest - }, - ref - ) => ( - - - - {children} - - - ) -); diff --git a/packages/gamut/src/InternalFloatingCard/__tests__/InternalFloatingCard.test.tsx b/packages/gamut/src/InternalFloatingCard/__tests__/InternalFloatingCard.test.tsx deleted file mode 100644 index 44c891bda2b..00000000000 --- a/packages/gamut/src/InternalFloatingCard/__tests__/InternalFloatingCard.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { DotDense } from '@codecademy/gamut-patterns'; -import { setupRtl } from '@codecademy/gamut-tests'; - -import { InternalFloatingCard } from '../InternalFloatingCard'; - -const renderView = setupRtl(InternalFloatingCard, { - children: 'Float on okay', -}); - -describe('InternalFloatingCard', () => { - it('renders with default props', () => { - const { props, view } = renderView(); - view.getByText('Float on okay'); - view.getByTitle('Checker Dense'); - expect(props.shadow).toBeUndefined(); - }); - - it('renders the default pattern when no pattern prop is passed', () => { - const { view } = renderView(); - view.getByTitle('Checker Dense'); - }); - - it('renders a non-default pattern when passed a valid Gamut pattern', () => { - const { view } = renderView({ pattern: DotDense }); - view.getByTitle('Dot Dense'); - }); -}); diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index 6b2773b566a..f8eea4af20d 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -143,10 +143,10 @@ const activeStates = system.states({ bg: 'background-selected', }, active: { - fontWeight: 700, + fontWeight: 'title', }, 'active-navlink': { - fontWeight: 700, + fontWeight: 'title', bg: 'background-selected', [Selectors.BEFORE]: { content: "''", diff --git a/packages/gamut/src/Modals/elements.tsx b/packages/gamut/src/Modals/elements.tsx index 46904fc61dd..4413f63b311 100644 --- a/packages/gamut/src/Modals/elements.tsx +++ b/packages/gamut/src/Modals/elements.tsx @@ -2,7 +2,7 @@ import { variant } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; -import { InternalFloatingCard } from '../InternalFloatingCard/InternalFloatingCard'; +import { PatternBackdrop } from '../PatternBackdrop/PatternBackdrop'; import { focusVisibleStyle } from '../utils'; const modalFocusVisibleStyle = focusVisibleStyle(); @@ -50,7 +50,7 @@ export interface ModalContainerProps extends StyleProps, StyleProps {} -export const ModalContainer = styled(InternalFloatingCard)( +export const ModalContainer = styled(PatternBackdrop)( sizeVariant, layoutVariant ); diff --git a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx new file mode 100644 index 00000000000..c3d6bcd8b15 --- /dev/null +++ b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx @@ -0,0 +1,44 @@ +import { CheckerDense } from '@codecademy/gamut-patterns'; +import { styledOptions, system } from '@codecademy/gamut-styles'; +import { StyleProps, variance } from '@codecademy/variance'; +import styled from '@emotion/styled'; +import { ComponentProps, forwardRef } from 'react'; + +import { Box } from '../Box'; + +const backdropBodyProps = variance.compose(system.layout, system.padding); + +const PatternBackdropBody = styled('div', styledOptions)< + StyleProps +>( + system.css({ + position: 'relative', + zIndex: 1, + bg: 'background', + border: 1, + maxWidth: 1, + }), + backdropBodyProps +); + +type PatternBackdropProps = ComponentProps; + +/** + * Internal bordered surface with a bottom-left checker pattern offset. + * Composed via `styled(PatternBackdrop)` by Toast and Modal; not exported — use those or `Card`. + */ +export const PatternBackdrop = forwardRef( + ({ children, ...rest }, ref) => ( + + + + {children} + + + ) +); diff --git a/packages/gamut/src/PatternBackdrop/__tests__/PatternBackdrop.test.tsx b/packages/gamut/src/PatternBackdrop/__tests__/PatternBackdrop.test.tsx new file mode 100644 index 00000000000..34804ebe676 --- /dev/null +++ b/packages/gamut/src/PatternBackdrop/__tests__/PatternBackdrop.test.tsx @@ -0,0 +1,15 @@ +import { setupRtl } from '@codecademy/gamut-tests'; + +import { PatternBackdrop } from '../PatternBackdrop'; + +const renderView = setupRtl(PatternBackdrop, { + children: 'Float on okay', +}); + +describe('PatternBackdrop', () => { + it('renders children with the default checker pattern', () => { + const { view } = renderView(); + view.getByText('Float on okay'); + view.getByTitle('Checker Dense'); + }); +}); diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx index 679eee34f6a..a60fa7546e7 100644 --- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx @@ -40,6 +40,7 @@ export const PopoverContainer: React.FC = ({ targetRef, allowPageInteraction, closeOnViewportExit = false, + focusOnProps, ...rest }) => { const popoverRef = useRef(null); @@ -265,6 +266,7 @@ export const PopoverContainer: React.FC = ({ const content = ( diff --git a/packages/gamut/src/PopoverContainer/types.ts b/packages/gamut/src/PopoverContainer/types.ts index fe80cabd57c..94a6744b620 100644 --- a/packages/gamut/src/PopoverContainer/types.ts +++ b/packages/gamut/src/PopoverContainer/types.ts @@ -1,5 +1,6 @@ import { RefObject } from 'react'; +import { FocusTrapProps } from '../FocusTrap'; import { WithChildrenProp } from '../utils'; export type Alignments = @@ -68,7 +69,8 @@ export interface PopoverPositionConfig extends PopoverAlignment { export interface PopoverContainerProps extends PopoverAlignment, - WithChildrenProp { + WithChildrenProp, + Pick { className?: string; /** * Whether the popover is rendered. diff --git a/packages/gamut/src/RadialProgress/index.tsx b/packages/gamut/src/RadialProgress/index.tsx index e39d8c23d80..2fe131a2417 100644 --- a/packages/gamut/src/RadialProgress/index.tsx +++ b/packages/gamut/src/RadialProgress/index.tsx @@ -33,11 +33,6 @@ const RadialProgressWrapper = styled.figure<{ size: number | string }>( }) ); -/** - * @deprecated - * This component is deprecated and is no longer supported. - */ - export const RadialProgress: React.FC = ({ children, className, diff --git a/packages/gamut/src/Tabs/TabButton.tsx b/packages/gamut/src/Tabs/TabButton.tsx index 8a99b1566b1..0cbaf3f2582 100644 --- a/packages/gamut/src/Tabs/TabButton.tsx +++ b/packages/gamut/src/Tabs/TabButton.tsx @@ -15,7 +15,7 @@ export interface TabButtonProps TabElementStyleProps {} const tabSelectedStyles = { - fontWeight: 700, + fontWeight: 'title', pt: 12, pb: 8, borderBottomWidth: 4, diff --git a/packages/gamut/src/Toast/Toast.tsx b/packages/gamut/src/Toast/Toast.tsx index a792dbd5860..3d43a68ddc5 100644 --- a/packages/gamut/src/Toast/Toast.tsx +++ b/packages/gamut/src/Toast/Toast.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { Box, FlexBox } from '../Box'; import { IconButton } from '../Button/IconButton'; -import { InternalFloatingCard } from '../InternalFloatingCard/InternalFloatingCard'; +import { PatternBackdrop } from '../PatternBackdrop/PatternBackdrop'; import { Text } from '../Typography'; import { WithChildrenProp } from '../utils'; @@ -30,7 +30,7 @@ const layoutVariants = system.variant({ }, }); -const ToastContainer = styled(InternalFloatingCard)< +const ToastContainer = styled(PatternBackdrop)< StyleProps >( system.css({ diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index 2720269977a..55192cf5ecc 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -16,6 +16,7 @@ export * from './Card'; export * from './Coachmark'; export * from './ConnectedForm'; export * from './ContentContainer'; +export * from './DatePicker'; export * from './DelayedRenderWrapper'; export * from './Disclosure'; export * from './DataList'; diff --git a/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx b/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx index 0e55152fda5..03c19bdc0c8 100644 --- a/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx +++ b/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx @@ -19,7 +19,7 @@ import { import { ThemeProvider } from 'storybook/theming'; import { useMemo } from 'react'; import { HelmetProvider } from 'react-helmet-async'; -import theme from '../../theming/GamutTheme'; +import { createGamutDocsTheme } from '../../theming/GamutTheme'; import { createTheme } from '@codecademy/variance'; export const storybookTheme = createTheme(coreTheme) .addColors(platformPalette) @@ -74,8 +74,17 @@ export const DocsContainer: React.FC<{ }; }, [storyId, globalTheme]); + const storybookDocsChromeTheme = useMemo( + () => createGamutDocsTheme(currentTheme.fontFamily.base), + [currentTheme] + ); + return ( - + = ({ diff --git a/packages/styleguide/.storybook/theming/GamutTheme.ts b/packages/styleguide/.storybook/theming/GamutTheme.ts index f6340392f62..9cff8c11642 100644 --- a/packages/styleguide/.storybook/theming/GamutTheme.ts +++ b/packages/styleguide/.storybook/theming/GamutTheme.ts @@ -9,36 +9,46 @@ import logo from '../assets/logo.svg'; const isLocalhost = globalThis.location?.toString().includes('localhost'); -export default create({ - base: 'light', - brandTitle: isLocalhost ? 'Gamut Local' : 'Gamut', - brandImage: logo, - brandUrl: '/', - fontBase: gamutTheme.fontFamily.base, - - // - colorPrimary: trueColors.hyper, - colorSecondary: trueColors.navy, - - // UI - appBg: trueColors.white, - appContentBg: isLocalhost ? trueColors.beige : trueColors.paleBlue, - appBorderColor: trueColors.navy, - appBorderRadius: 4, - - // Text colors - textColor: trueColors.navy, - textInverseColor: trueColors.white, - textMutedColor: coreSwatches.gray[800], - - // Toolbar default and active colors - barTextColor: coreSwatches.gray[600], - barSelectedColor: trueColors.navy, - barBg: trueColors.white, - - // Form colors - inputBg: trueColors.white, - inputBorder: trueColors.navy, - inputTextColor: trueColors.navy, - inputBorderRadius: 2, -}); +/** + * Storybook docs chrome (`parameters.docs.theme`) uses `fontBase` for MDX canvas + * typography. It must match the toolbar-selected Gamut theme’s `fontFamily.base` + * (see DocsContainer); otherwise e.g. LX Studio shows Skillsoft Text on `body` + * from Reboot but Apercu inside addon-docs div rules. + */ +export function createGamutDocsTheme( + fontBase: string = gamutTheme.fontFamily.base +) { + return create({ + base: 'light', + brandTitle: isLocalhost ? 'Gamut Local' : 'Gamut', + brandImage: logo, + brandUrl: '/', + fontBase, + colorPrimary: trueColors.hyper, + colorSecondary: trueColors.navy, + + // UI + appBg: trueColors.white, + appContentBg: isLocalhost ? trueColors.beige : trueColors.paleBlue, + appBorderColor: trueColors.navy, + appBorderRadius: 4, + + // Text colors + textColor: trueColors.navy, + textInverseColor: trueColors.white, + textMutedColor: coreSwatches.gray[800], + + // Toolbar default and active colors + barTextColor: coreSwatches.gray[600], + barSelectedColor: trueColors.navy, + barBg: trueColors.white, + + // Form colors + inputBg: trueColors.white, + inputBorder: trueColors.navy, + inputTextColor: trueColors.navy, + inputBorderRadius: 2, + }); +} + +export default createGamutDocsTheme(); diff --git a/packages/styleguide/src/lib/Atoms/AboutToC.tsx b/packages/styleguide/src/lib/Atoms/AboutToC.tsx index d74377b3cfb..57a99ffed7b 100644 --- a/packages/styleguide/src/lib/Atoms/AboutToC.tsx +++ b/packages/styleguide/src/lib/Atoms/AboutToC.tsx @@ -9,7 +9,6 @@ import { parameters as formElementsParameters } from './FormElements/About.mdx'; import { parameters as formInputsParameters } from './FormInputs/About.mdx'; import { parameters as iconsParameters } from './Icons/About.mdx'; import { parameters as illustrationsParameters } from './Illustrations/Illustrations.mdx'; -import { parameters as internalFloatingCardParameters } from './InternalFloatingCard/InternalFloatingCard.mdx'; import { parameters as loadersParameters } from './Loaders/About.mdx'; import { parameters as patternsParameters } from './Patterns/Patterns.mdx'; import { parameters as popoverContainerParameters } from './PopoverContainer/PopoverContainer.mdx'; @@ -32,7 +31,6 @@ export const AtomAboutToC = () => ( formInputsParameters, iconsParameters, illustrationsParameters, - internalFloatingCardParameters, loadersParameters, patternsParameters, popoverContainerParameters, diff --git a/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.mdx b/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.mdx deleted file mode 100644 index 4ff65a8d9ad..00000000000 --- a/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.mdx +++ /dev/null @@ -1,91 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { Callout, ComponentHeader, LinkTo } from '~styleguide/blocks'; - -import * as InternalFloatingCardStories from './InternalFloatingCard.stories'; - -export const parameters = { - title: 'InternalFloatingCard', - subtitle: - 'A card with a persistent patterned shadow meant for user feedback or interaction outside the normal flow.', - status: 'deprecated', - design: { - type: 'figma', - url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=29975-39392&node-type=frame&t=SgphNxlCwEFrcv0X-0', - }, - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/InternalFloatingCard', - }, -}; - - - - - - - The InternalFloatingCard component is strictly for internal Gamut usage. - For instance, the{' '} - Modal component will use - InternalFloatingCard to style its content. However, InternalFloatingCard - is no longer exported for use in other libraries, in such cases, use{' '} - Card instead. - - } -/> - -## Usage - -- They have patterned drop shadow to indicate that are outside the normal document flow. -- This is a shared component used to create `Toast`, `Dialog`, and `Coachmark`; before using this component directly please check that these components do not cover your use case! -- `Tooltip` shares many styles with InternalFloatingCards but is used more widely and does not have a shadow as that might detract from the critical functions it serves. - -## Beaks - -InternalFloatingCards can optionally display a beak. This is used to indicate a point of interest to the user - that the content of the card describes. Beaks are primarily used in `Coachmarks`. - - - - - - -## Shadow direction - -There are 2 types of shadow directions. The default shadow offset is bottom left. - - - - -## Shadow pattern - -We can specify the pattern of the InternalFloatingCard shadow by providing the `pattern` prop with the imported pattern from `@codecademy/gamut-patterns`. - -See the `Patterns` story to view all the possible pattern options. - - - -## Card wrapper display - -By default a `InternalFloatingCard` has a wrapper with a display of `inline-block`. However, if you need to you can set the `containerDisplay` to `block`, as shown below: - -```tsx -import { InternalFloatingCard } from '@codecademy/gamut'; -import { DotDense } from '@codecademy/gamut-patterns'; - -export const MyComponent: React.FC = () => { - return ( - - Take up all the space. - - ); -}; -``` - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.stories.tsx b/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.stories.tsx deleted file mode 100644 index 0c1079b93e1..00000000000 --- a/packages/styleguide/src/lib/Atoms/InternalFloatingCard/InternalFloatingCard.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { DotDense } from '@codecademy/gamut-patterns'; -import * as patterns from '@codecademy/gamut-patterns'; -import type { Meta, StoryObj } from '@storybook/react'; - -// Importing directly from the Gamut to avoid exporting the component from the package and still use the component in this story -import { InternalFloatingCard } from '../../../../../gamut/src/InternalFloatingCard/InternalFloatingCard'; - -const meta: Meta = { - component: InternalFloatingCard, - args: { - children: "Yakety Yak don't don't talk back!", - pattern: undefined, - }, - argTypes: { - pattern: { - control: 'select', - options: Object.keys(patterns), - mapping: patterns, - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, -}; - -export const BeakBottomLeft: Story = { - args: { - beak: 'bottom-left', - children: 'Beak Bottom Left', - }, -}; - -export const BeakBottomRight: Story = { - args: { - beak: 'bottom-right', - children: 'Beak Bottom Right', - }, -}; - -export const BeakTopLeft: Story = { - args: { - beak: 'top-left', - children: 'Beak Top Left', - }, -}; - -export const BeakTopRight: Story = { - args: { - beak: 'top-right', - children: 'Beak Top Right', - }, -}; - -export const ShadowBottomLeft: Story = { - args: { - shadow: 'bottomLeft', - children: 'Shadow Bottom Left', - }, -}; - -export const ShadowBottomRight: Story = { - args: { - shadow: 'bottomRight', - children: 'Shadow Bottom Right', - }, -}; - -export const ShadowPattern: Story = { - args: { - pattern: DotDense, - }, -}; diff --git a/packages/styleguide/src/lib/Foundations/Theme/LXStudioTheme.mdx b/packages/styleguide/src/lib/Foundations/Theme/LXStudioTheme.mdx index cff103a3988..ac141100995 100644 --- a/packages/styleguide/src/lib/Foundations/Theme/LXStudioTheme.mdx +++ b/packages/styleguide/src/lib/Foundations/Theme/LXStudioTheme.mdx @@ -30,12 +30,12 @@ If you are using the LX Studio Theme via the Gamut theme provider in your app (i import { css } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; -const Box = styled.div(css({ bg: 'lxStudioPurple', p: 4 })); // our system props inherently use the current theme +const Box = styled.div(css({ bg: 'sapphire', p: 4 })); // our system props inherently use the current theme const styles = styled.div` blue: ${({ theme }) => theme.colors - .lxStudioPurple}; // theme here refers to the current theme, which is the LX Studio theme + .sapphire}; // theme here refers to the current theme, which is the LX Studio theme `; ``` @@ -46,12 +46,12 @@ import { css } from '@emotion/react'; import { lxStudioTheme, theme } from '@codecademy/gamut-styles'; const myStyles = css` - color: ${lxStudioTheme.colors.lxStudioPurple}; + color: ${lxStudioTheme.colors.sapphire}; `; const notWorkingStyles = css` color: ${theme.colors - .lxStudioPurple}; // teal does not exist in the Core Theme colors so this does not work + .sapphire}; // sapphire does not exist on the Core Theme colors so this does not work `; ``` diff --git a/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx b/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx index 0ff44224640..ccccfd3e977 100644 --- a/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx +++ b/packages/styleguide/src/lib/Foundations/Theme/PercipioTheme.mdx @@ -30,12 +30,12 @@ If you are using the Percipio Theme via the Gamut theme provider in your app (i. import { css } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; -const Box = styled.div(css({ bg: 'percipioActionPrimary', p: 4 })); // our system props inherently use the current theme +const Box = styled.div(css({ bg: 'sapphire', p: 4 })); // our system props inherently use the current theme const styles = styled.div` color: ${({ theme }) => theme.colors - .percipioActionPrimary}; // theme here refers to the current theme, which is the Percipio theme + .sapphire}; // theme here refers to the current theme, which is the Percipio theme `; ``` @@ -46,12 +46,12 @@ import { css } from '@emotion/react'; import { percipioTheme, theme } from '@codecademy/gamut-styles'; const myStyles = css` - color: ${percipioTheme.colors.percipioActionPrimary}; + color: ${percipioTheme.colors.sapphire}; // exists on the Percipio theme `; const notWorkingStyles = css` color: ${theme.colors - .percipioActionPrimary}; // percipioActionPrimary does not exist in the Core Theme colors so this does not work + .sapphire}; // sapphire is not on the default Core theme export—use percipioTheme `; ``` @@ -93,6 +93,6 @@ Percipio currently only supports `light` mode. **Key**: `fontFamily` -The Percipio theme uses Roboto fonts for all font families to provide a consistent typography experience. +The Percipio theme uses Skillsoft Sans for accent text and Skillsoft Text for body text. Roboto Mono is used for monospace, and Roboto sans-serif for the `system` font family slot. diff --git a/packages/styleguide/src/lib/Foundations/Typography.mdx b/packages/styleguide/src/lib/Foundations/Typography.mdx index 340c4f6a863..26998c78db0 100644 --- a/packages/styleguide/src/lib/Foundations/Typography.mdx +++ b/packages/styleguide/src/lib/Foundations/Typography.mdx @@ -10,7 +10,7 @@ export const parameters = { status: 'static', design: { type: 'figma', - url: 'https://www.figma.com/file/rNsdbOwlw6L0ea3uAJrrz7', + url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=412-324', }, }; @@ -20,46 +20,19 @@ export const parameters = { For more checkout out our theme documentation for how to access and use all typography design tokens. -Our primary typeface is Aperçu Pro. It is a geometric-ish, humanist-ish sans-serif. Its voice says _“we are ruled by logic, but are creative and a bit unexpected as well”_. Its weight makes it attention-grabbing in dense layouts, and its legibility allows us to use it for both long-form articles and fine print. - -We use Suisse Int’l Mono in order to accent designs with references to the tools, techniques and history of programming and technology. It is the seasoning we use to give our designs flavor and personality. Suisse is equally at home debating tabs v. spaces, reciting poetry, sharing tips on new courses and processing payments. But be careful—it should not be overused! - ## Typefaces (`fontFamily`) -- Most of our designs use Aperçu Pro Bold, (Headlines, Titles) Regular (Paragraph Text, Menu Items, UI) or Italic (for emphasis) -- Do not use Aperçu Bold to emphasize text within a line/paragraph of Aperçu Regular -- Suisse Int’l Mono is used as an accent, in cases where items are listed/enumerated, for quotations, labels, and in other special cases. -- Suisse Int’l Mono reads quite large for its point size, and requires extra line-height in order to be readable as text. Styles have been adjusted to account for these features. -- Text should be left-aligned by default, using centering in some cases for Marketing emphasis or Interface specifics. You should never right-align text under normal circumstances - - - -### Aperçu Pro - -Most type should be set in Aperçu. Use the Regular weight for long-form reading and areas of “default” text. Use Bold when setting important headlines, to set sub-headlines apart from similarly sized text, and for CTAs or buttons. - -Aperçu should be set with a generous line-height wherever possible in order to improve readability and to avoid cramping your layout. We recommend using between 150–175% of the type size (example, 16px type would ideally use 24–28px of line-height). - -Headlines should be set with reduced line-height in order to appear neat and intentional, and to help group the headline as a distinct semantic unit even when in a larger layout. 100–110% is a good default. - -### Suisse - -When setting text in Suisse Int’l Mono, you should be aware that it is a monospace typeface, which means that every character is exactly the same width. This causes letters in paragraphs to stack into vertical columns, which can make reading it for extended periods straining. - -Suisse is great for displaying numbers and snippets of code, and can be used to associate captions, facts/figures and lists with computer science, techinical rigor and the engineering community at large. - -Suisse looks very large for its size, which means that typically you will want to reduce its point size by 10-15% in order for it to visually match text set in Aperçu. The text to the right was set in 14px in order to appear roughly the same size as Aperçu at 16px. + ## Font sizes -- We use the term “Title” to distinguish our visual sizing from semantic designations like “H1” +We use the term “Title” to distinguish our visual sizing from semantic designations like “H1” ## Line heights -- Line heights are limited to multiples of 4px -- Type boxes should be placed on the 8px placement grid like all other elements. +Type boxes should be placed on the 8px placement grid like all other elements. diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index f6bbb831032..370f2ab926e 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -12,6 +12,7 @@ import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import kebabCase from 'lodash/kebabCase'; +import { useMemo } from 'react'; import { Code, ColorScale, LinkTo, TokenTable } from '~styleguide/blocks'; @@ -271,6 +272,43 @@ export const fontFamily = { ], }; +export const FontFamilyTable = () => { + const currentTheme = useTheme() as typeof theme; + + const { rows, columns } = useMemo( + () => ({ + rows: Object.entries(currentTheme.fontFamily).map(([id, value]) => ({ + id, + value: value as string, + })), + columns: [ + PROP_COLUMN, + { + ...PATH_COLUMN, + size: 'xl', + render: ({ id }) => theme.fontFamily.{id}, + }, + { + ...VALUE_COLUMN, + render: ({ value }) => ( + + {value.split(',')[0]} + + ), + size: 'lg', + }, + { + ...createExampleColumn({ text: 'Example Text', prop: 'fontFamily' }), + size: 'fill', + }, + ], + }), + [currentTheme] + ); + + return ; +}; + export const fontWeight = { rows: Object.entries(theme.fontWeight).map(([id, value]) => ({ id, diff --git a/packages/styleguide/src/lib/Meta/Installation.mdx b/packages/styleguide/src/lib/Meta/Installation.mdx index ee9e65f6b93..72facfbced3 100644 --- a/packages/styleguide/src/lib/Meta/Installation.mdx +++ b/packages/styleguide/src/lib/Meta/Installation.mdx @@ -108,3 +108,26 @@ If your app uses a strict Content-Security-Policy (e.g. `style-src` without `'un ``` Your nonce should be the same value you use in your CSP header (e.g. `style-src 'self' 'nonce-{value}'`). + +## Testing with Jest (and Babel) + +Gamut depends on packages that ship modern JavaScript from `node_modules`. Jest often skips transpiling `node_modules`, so importing Gamut in tests can surface parse or syntax errors unless your repo opts those dependencies into your test transform. + +If tests only fail after upgrading Gamut or a transitive dependency, compare your Jest and Babel setup with Gamut's and adjust `transformIgnorePatterns` and plugins accordingly. + +### `transformIgnorePatterns` + +Allow your test transformer (for example `babel-jest`) to run on specific packages. Gamut's own Jest setup uses a negative lookahead so `@vidstack/react` and `@formatjs` are not ignored: + +```js +// jest.config.js (or equivalent) +transformIgnorePatterns: ['node_modules/(?!(@vidstack/react|@formatjs)/)'], +``` + +Add more package scopes inside the non-capturing group if your tree pulls in other untranspiled ESM (for example `|@your-scope`). + +### Babel and newer syntax + +Even when those packages are transformed, older Babel configs may not handle every language feature. + +To fix that, the necessary plugins need to be added to the Babel config Jest uses, or upgrade **`@babel/preset-env`** / Babel so that feature is covered. diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx new file mode 100644 index 00000000000..dc26b4bb61b --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx @@ -0,0 +1,398 @@ +import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; + +import { + ComponentHeader, + ImageWrapper, + KeyboardKey, + LinkTo, +} from '~styleguide/blocks'; + +import * as DatePickerStories from './DatePicker.stories'; + +export const parameters = { + title: 'DatePicker', + design: { + type: 'figma', + url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=127461-42132', + }, + subtitle: `Single-date or range selection with a segmented date field, calendar popover, footer quick actions, and shared React context for custom layouts.`, + status: 'updating', + source: { + repo: 'gamut', + githubLink: + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DatePicker/DatePicker.tsx', + }, +}; + + + + + +## Usage + +Use `DatePicker` to allow users to select a single date or a date range via manual input or an interactive calendar. + +### Best practices: + +- Include quick actions when users are likely to select common or recurring dates or ranges +- Include disabled dates to prevent selection of dates that are unavailable or invalid in your context + +### When NOT to use: + +- **Predefined options** - for choosing from a fixed set of date-related options (e.g., "This month", "Last quarter"), use the Select or SelectDropdown component instead + +## Anatomy + + + +1. **Start date / Date** + +- The first date in a range, or the only date when `mode="single"` +- Label reads "Date" for single date; "Start date" for range +- Inputting a date updates the calendar selection immediately + +2. **End date** + +- The last date in the selected range +- Label reads "End date" +- Inputting a date updates the calendar selection immediately +- Only shown if `mode="range"` + +3. **Calendar** + +- Popover opens on click of either date input, or on Alt + ↓ or Option + ↓ when an input is focused +- Closes automatically once the selection is complete +- Selecting a date in the calendar updates the manual inputs immediately +- Clicking an already-selected date clears it +- Displays 2 months by default; collapses to 1 month at viewports below 768px + +4. **Today** + +- Indicates the current date on the calendar to orient the user +- Indicator is primary-colored when the date is not selected; switches to the background token when selected + +5. **Disabled dates** _(optional)_ + +- Use to mark specific dates as non-selectable +- If a selected range overlaps any disabled date, the range is invalid +- An invalid range clears the End date and treats the click as a new Start date selection + +6. **Clear** + +- Clears all current date selections +- Only shown if `mode="range"` + +7. **Quick actions** _(optional)_ + +- Use when you expect users to frequently select common dates or ranges +- Defaults: "Last 7 days," "Last 30 days," "Last 90 days" for `mode="range"`; "Yesterday," "Today," "Tomorrow" for `mode="single"` +- Include up to 3 actions +- Use range quick actions when `mode="range"`; use single-date quick actions when `mode="single"` +- For past date ranges, use "Last" as the label prefix (e.g., "Last 7 days") +- Quick action ranges are inclusive of today +- If the resulting range overlaps any disabled dates, no action is taken + +## Variants + +### Single date + +Use `mode="single"` when users need to select a single date. + +`selectedDate` and `onSelected` are the controlled value and updater and are required props. + +```tsx +const [selectedDate, setSelectedDate] = useState(null); +return ( + +); +``` + + + +### Range + +Use `mode="range"` when users need to select a range of dates. + +`startDate`, `onStartSelected`, `endDate`, and `onEndSelected` are the controlled values and updaters and are required props. + +```tsx +const [startDate, setStartDate] = useState(null); +const [endDate, setEndDate] = useState(null); +return ( + +); +``` + + + +### With initial date + +Set the initial date using the `selectedDate` (single) or `startDate` and `endDate` (range) props. + + + + + +### Placement + +Use the `placement` prop to control whether the calendar popover renders inside the current DOM context (inline) or escapes with a portal (floating). The default placement is `"inline"`. + +#### Inline + + + +#### Floating + + + +### Input size + +Use the `inputSize` prop to adjust the size of the input. The default size is `"base"`. `"small"` can be used to reduce the padding of the input: + + + +## Quick actions + +Quick actions are a set of predefined options that allow users to quickly select a date or range of dates. At most, 3 quick actions can be set for a calendar. + +### Default values + +There are default quick actions for both single and range modes for convenience. + +#### Single date: yesterday / today / tomorrow + + + +#### Range: last 7 / 30 / 90 days + + + +### Disable quick actions + +Disable quick actions by passing `quickActions={null}`. + + + +When quick actions are disabled in single mode, the footer is not shown. + + + +### Custom values + +Use the `quickActions` prop to pass custom quick actions. `quickActions` is an array of quick actions, each quick action is an object with the following properties: + +- `num`: the number of days to offset from today. +- `timePeriod`: the time period to offset from today. +- `displayText`: the text to display for the quick action. +- `onClick`: the callback to call when the quick action is clicked. + + + + + +## Disable dates + +Use the `disableDate` callback prop to disable specific dates in the calendar. The callback should return `true` if the date should be disabled and `false` if it should be enabled. Dates can be disabled based on a fixed list of dates, the current date, the start date, or any other date property. Below are some common examples that showcase how versatile `disableDate` can be: + +### Disable a fixed list of dates + +Use the `matchDisabledDates` helper in `disableDate` to disable a fixed list of dates. Here we are disabling the date April 22, 2026. + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={matchDisabledDates([new Date(2026, 3, 22)])} +/> +``` + + + +### Disable dates based on the current date + +Example of disabling dates that are before today: + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => date < new Date()} +/> +``` + + + +Example of disabling dates that are more than 30 days before today: + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => { + const today = new Date(); + const thirtyDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 30 + ); + return date < thirtyDaysAgo; + }} +/> +``` + + + +### Disable dates based on the start date + +We can limit the range of dates that can be selected based on the start date. For example, here we are disabling dates that are more than 30 days before or after the start date, effectively limiting the range to 30 days. + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => { + if (startDate === null) { + return false; + } + const start = calendarDate(startDate); + const min = new Date(start); + min.setDate(min.getDate() - 30); + const max = new Date(start); + max.setDate(max.getDate() + 30); + const day = calendarDate(date); + return day < min || day > max; + }} +/> +``` + + + +### Disable weekends + +```tsx + {}} + endDate={null} + disableDate={(date) => date.getDay() === 0 || date.getDay() === 6} +/> +``` + + + +## Locale + +Use the `locale` prop to set the locale of the DatePicker component. The default locale is the runtime default +locale. A [BCP 47 language tag](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) (e.g. `"en-US"`, `"fr-FR"`) or a [`Intl.Locale` object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) can also be passed. + +DatePicker uses the `Intl` API to internationalize a lot of the component automatically: + +- `Intl.Locale` + `getWeekInfo`: which day starts the calendar. +- `Intl.DateTimeFormat`: segment order and separators for the inputs; month + year in the header; short/long weekday names in the grid; formatted day cell `aria-label`. +- `Intl.RelativeTimeFormat`: the default single-mode quick actions (e.g. yesterday / today / tomorrow) and the previous / next month navigation tip text. +- `String.prototype.toLocaleUpperCase`: first character of some relative-time strings, using the active locale. + +Here we are setting the locale to 'de-DE' (German): + + + +### Translations + +Use the `translations` prop to internationalize any text that is not handled automatically by the `Intl` API. The passed translations are merged with the default translations from `DEFAULT_DATE_PICKER_TRANSLATIONS`. + +```tsx +export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = + { + clearButtonText: 'Clear', + dateLabel: 'Date', + startDateLabel: 'Start date', + endDateLabel: 'End date', + calendarDialogAriaLabel: 'Choose date', + last7DaysDisplayText: 'Last 7 days', + last30DaysDisplayText: 'Last 30 days', + last90DaysDisplayText: 'Last 90 days', + }; +``` + + + +## Composed with context + +`DatePicker` can be rendered with `children` and composed for more flexibility. The following components/hooks are exported and available to use for composition: + +- `useDatePicker()` exposes shared shell state. Always narrow on `mode` when mode-specific fields are needed. +- `DatePickerInput`: month/day/year `role="spinbutton"` segments. In range mode use `rangePart="start"` or `"end"` on each instance. +- `DatePickerCalendar`: calendar UI wired to context; pass a stable `dialogId` that matches the `id` on the `role="dialog"` wrapper for `aria-labelledby` / `aria-controls` wiring. + +Example component to be passed as `children` to `DatePicker`: + +```tsx +export const ComposedDatePickerLayout: React.FC = () => { + const { isCalendarOpen, closeCalendar } = useDatePicker(); + const inputRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; +``` + + + +## Playground + + + + + +## Accessibility considerations + +- The calendar lives in a `role="dialog"` region; pair its `id` with `DatePickerCalendar`'s `dialogId` and input `aria-controls` from the default implementation. +- Segments use `role="spinbutton"`; arrow keys adjust values, `Arrow Left/Right` move between segments, and `Alt+Arrow Down` opens the calendar or moves focus into the grid when appropriate. +- `Escape` closes the popover. diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..79019732d98 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,618 @@ +import { + Box, + DatePicker, + DatePickerCalendar, + DatePickerInput, + matchDisabledDates, + PopoverContainer, + useDatePicker, +} from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; + +const storybookLocaleOptions = [ + undefined, + 'en', + 'en-US', + 'en-GB', + 'de-DE', + 'es', + 'es-ES', + 'fr-FR', + 'ja-JP', + 'pt-BR', + 'zh-CN', + 'ko-KR', + 'it-IT', + 'nl-NL', + 'pl-PL', + 'ru-RU', + 'sv-SE', + 'tr-TR', + 'hi-IN', + 'ar', + 'ar-SA', + 'he-IL', + 'th-TH', +] as const; + +const meta: Meta = { + component: DatePicker, + title: 'Organisms/DatePicker', + args: { + mode: 'single', + locale: 'en-US', + }, + argTypes: { + mode: { + description: 'Pick a single date or a range of dates.', + type: { + name: 'enum', + value: ['single', 'range'], + required: true, + }, + control: { type: 'radio' }, + table: { type: { summary: "'single' | 'range'" } }, + }, + locale: { + description: + 'BCP 47 language tag for `Intl` segment order, month names, and quick-action relative dates. `undefined` uses runtime default.', + control: { type: 'select' }, + options: [...storybookLocaleOptions], + }, + selectedDate: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + onSelected: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + startDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + endDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onStartSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onEndSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + if (args.mode === 'range') { + return ( + + ); + } + + return ( + + ); + }, +}; + +const fixedMode = (mode: 'single' | 'range') => + ({ + args: { mode }, + argTypes: { + mode: { control: false }, + }, + } as const); + +export const SingleDate: Story = { + ...fixedMode('single'), + decorators: [ + (Story) => ( + + + + ), + ], + args: { placement: 'inline' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const Range: Story = { + ...fixedMode('range'), + decorators: [ + (Story) => ( + + + + ), + ], + args: { placement: 'inline' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const WithInitialDateSingle: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState( + () => new Date(2026, 1, 15) + ); + return ( + + ); + }, +}; + +export const WithInitialDateRange: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState( + () => new Date(2026, 1, 15) + ); + const [endDate, setEndDate] = useState( + () => new Date(2026, 1, 20) + ); + return ( + + ); + }, +}; + +export const FloatingPlacement: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeSmall: Story = { + ...fixedMode('range'), + args: { inputSize: 'small', placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleDefaultQuickActions: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDefaultQuickActions: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleNoQuickActions: Story = { + ...fixedMode('single'), + args: { quickActions: null, placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeNoQuickActions: Story = { + ...fixedMode('range'), + args: { quickActions: null, placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleCustomQuickActions: Story = { + ...fixedMode('single'), + args: { + placement: 'floating', + quickActions: [ + { num: -3, timePeriod: 'day', displayText: '3 days ago' }, + { num: 0, timePeriod: 'day', displayText: 'Today' }, + { num: 3, timePeriod: 'day', displayText: 'In 3 days' }, + ], + }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeCustomQuickActions: Story = { + ...fixedMode('range'), + args: { + placement: 'floating', + quickActions: [ + { num: -7, timePeriod: 'day', displayText: 'Last 7 days' }, + { num: -14, timePeriod: 'day', displayText: 'Last 14 days' }, + { num: -30, timePeriod: 'day', displayText: 'Last 30 days' }, + ], + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const MatchDisabledDates: Story = { + ...fixedMode('range'), + args: { + disableDate: matchDisabledDates([new Date(2026, 3, 22)]), + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +const calendarDate = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +export const Range30DayWindowFromStart: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + return ( + { + if (startDate === null) { + return false; + } + const start = calendarDate(startDate); + const min = new Date(start); + min.setDate(min.getDate() - 30); + const max = new Date(start); + max.setDate(max.getDate() + 30); + const day = calendarDate(date); + return day < min || day > max; + }} + endDate={endDate} + mode="range" + startDate={startDate} + onEndSelected={setEndDate} + onStartSelected={setStartDate} + /> + ); + }, +}; + +export const RangeDisabledBeforeToday: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + return date < startOfToday; + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDisabledMoreThan30DaysBeforeToday: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => { + const today = new Date(); + const thirtyDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 30 + ); + return date < thirtyDaysAgo; + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDisabledWeekends: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => date.getDay() === 0 || date.getDay() === 6, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const Locale: Story = { + ...fixedMode('single'), + args: { locale: 'de-DE', placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const Translations: Story = { + ...fixedMode('range'), + args: { + locale: 'es', + translations: { + clearButtonText: 'Borrar', + dateLabel: 'Fecha', + startDateLabel: 'Fecha de inicio', + endDateLabel: 'Fecha de fin', + calendarDialogAriaLabel: 'Elegir fecha', + last7DaysDisplayText: 'Últimos 7 días', + last30DaysDisplayText: 'Últimos 30 días', + last90DaysDisplayText: 'Últimos 90 días', + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + return ( + + ); + }, +}; + +export const ComposedWithContext: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + + + ); + }, +}; + +const ComposedDatePickerLayout: React.FC = () => { + const { isCalendarOpen, closeCalendar } = useDatePicker(); + const inputRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/styleguide/src/lib/Organisms/Lists & Tables/List/List.stories.tsx b/packages/styleguide/src/lib/Organisms/Lists & Tables/List/List.stories.tsx index 79b6237c40f..cdd5feba009 100644 --- a/packages/styleguide/src/lib/Organisms/Lists & Tables/List/List.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/Lists & Tables/List/List.stories.tsx @@ -317,7 +317,7 @@ export const CondensedSpacingGuide: Story = { {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( - + {name} @@ -391,7 +391,7 @@ export const CondensedTableGuide: Story = { {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( - + {name} diff --git a/packages/styleguide/src/lib/Organisms/Lists & Tables/List/ListCol/ListCol.stories.tsx b/packages/styleguide/src/lib/Organisms/Lists & Tables/List/ListCol/ListCol.stories.tsx index 4cc200f8bcd..422e19c8393 100644 --- a/packages/styleguide/src/lib/Organisms/Lists & Tables/List/ListCol/ListCol.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/Lists & Tables/List/ListCol/ListCol.stories.tsx @@ -110,7 +110,7 @@ export const HorizontalScrolling: ListCompositionStory = { {rows.map(({ name, role, ship }, i, _, key = `example-row-${i}`) => ( - + {name} @@ -223,7 +223,7 @@ export const Responsive: ListCompositionStory = { - + Ordered List Header diff --git a/packages/styleguide/src/static/organisms/datepicker.png b/packages/styleguide/src/static/organisms/datepicker.png new file mode 100644 index 00000000000..11d0c49b40d Binary files /dev/null and b/packages/styleguide/src/static/organisms/datepicker.png differ diff --git a/yarn.lock b/yarn.lock index bc7185f860d..99b62f30966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1559,11 +1559,11 @@ __metadata: languageName: node linkType: hard -"@codecademy/gamut-icons@npm:9.57.3, @codecademy/gamut-icons@workspace:packages/gamut-icons": +"@codecademy/gamut-icons@npm:9.57.5, @codecademy/gamut-icons@workspace:packages/gamut-icons": version: 0.0.0-use.local resolution: "@codecademy/gamut-icons@workspace:packages/gamut-icons" dependencies: - "@codecademy/gamut-styles": "npm:17.14.0" + "@codecademy/gamut-styles": "npm:18.0.0" "@codecademy/variance": "npm:0.26.1" peerDependencies: "@emotion/react": ^11.4.0 @@ -1573,11 +1573,11 @@ __metadata: languageName: unknown linkType: soft -"@codecademy/gamut-illustrations@npm:0.58.10, @codecademy/gamut-illustrations@workspace:packages/gamut-illustrations": +"@codecademy/gamut-illustrations@npm:0.58.11, @codecademy/gamut-illustrations@workspace:packages/gamut-illustrations": version: 0.0.0-use.local resolution: "@codecademy/gamut-illustrations@workspace:packages/gamut-illustrations" dependencies: - "@codecademy/gamut-styles": "npm:17.14.0" + "@codecademy/gamut-styles": "npm:18.0.0" classnames: "npm:^2.2.5" peerDependencies: "@emotion/react": ^11.4.0 @@ -1591,22 +1591,22 @@ __metadata: version: 0.0.0-use.local resolution: "@codecademy/gamut-kit@workspace:packages/gamut-kit" dependencies: - "@codecademy/gamut": "npm:68.3.0" - "@codecademy/gamut-icons": "npm:9.57.3" - "@codecademy/gamut-illustrations": "npm:0.58.10" - "@codecademy/gamut-patterns": "npm:0.10.29" - "@codecademy/gamut-styles": "npm:17.14.0" - "@codecademy/gamut-tests": "npm:5.3.4" + "@codecademy/gamut": "npm:68.6.1" + "@codecademy/gamut-icons": "npm:9.57.5" + "@codecademy/gamut-illustrations": "npm:0.58.11" + "@codecademy/gamut-patterns": "npm:0.10.30" + "@codecademy/gamut-styles": "npm:18.0.0" + "@codecademy/gamut-tests": "npm:6.0.1" "@codecademy/variance": "npm:0.26.1" component-test-setup: "npm:^0.3.1" languageName: unknown linkType: soft -"@codecademy/gamut-patterns@npm:0.10.29, @codecademy/gamut-patterns@workspace:packages/gamut-patterns": +"@codecademy/gamut-patterns@npm:0.10.30, @codecademy/gamut-patterns@workspace:packages/gamut-patterns": version: 0.0.0-use.local resolution: "@codecademy/gamut-patterns@workspace:packages/gamut-patterns" dependencies: - "@codecademy/gamut-styles": "npm:17.14.0" + "@codecademy/gamut-styles": "npm:18.0.0" "@codecademy/variance": "npm:0.26.1" classnames: "npm:^2.2.5" peerDependencies: @@ -1617,7 +1617,7 @@ __metadata: languageName: unknown linkType: soft -"@codecademy/gamut-styles@npm:17.14.0, @codecademy/gamut-styles@workspace:packages/gamut-styles": +"@codecademy/gamut-styles@npm:18.0.0, @codecademy/gamut-styles@workspace:packages/gamut-styles": version: 0.0.0-use.local resolution: "@codecademy/gamut-styles@workspace:packages/gamut-styles" dependencies: @@ -1636,11 +1636,11 @@ __metadata: languageName: unknown linkType: soft -"@codecademy/gamut-tests@npm:5.3.4, @codecademy/gamut-tests@workspace:packages/gamut-tests": +"@codecademy/gamut-tests@npm:6.0.1, @codecademy/gamut-tests@workspace:packages/gamut-tests": version: 0.0.0-use.local resolution: "@codecademy/gamut-tests@workspace:packages/gamut-tests" dependencies: - "@codecademy/gamut-styles": "npm:17.14.0" + "@codecademy/gamut-styles": "npm:18.0.0" component-test-setup: "npm:^0.3.1" lodash: "npm:^4.17.23" peerDependencies: @@ -1648,15 +1648,16 @@ __metadata: languageName: unknown linkType: soft -"@codecademy/gamut@npm:68.3.0, @codecademy/gamut@workspace:packages/gamut": +"@codecademy/gamut@npm:68.6.1, @codecademy/gamut@workspace:packages/gamut": version: 0.0.0-use.local resolution: "@codecademy/gamut@workspace:packages/gamut" dependencies: - "@codecademy/gamut-icons": "npm:9.57.3" - "@codecademy/gamut-illustrations": "npm:0.58.10" - "@codecademy/gamut-patterns": "npm:0.10.29" - "@codecademy/gamut-styles": "npm:17.14.0" + "@codecademy/gamut-icons": "npm:9.57.5" + "@codecademy/gamut-illustrations": "npm:0.58.11" + "@codecademy/gamut-patterns": "npm:0.10.30" + "@codecademy/gamut-styles": "npm:18.0.0" "@codecademy/variance": "npm:0.26.1" + "@formatjs/intl-locale": "npm:5.3.1" "@react-aria/interactions": "npm:3.25.0" "@types/marked": "npm:^4.0.8" "@vidstack/react": "npm:^1.12.12" @@ -2261,6 +2262,13 @@ __metadata: languageName: node linkType: hard +"@formatjs/bigdecimal@npm:0.2.0": + version: 0.2.0 + resolution: "@formatjs/bigdecimal@npm:0.2.0" + checksum: 10c0/dec607e3d9d4b8c5d0474862e867726cbf322a24d543d5b2cbc3cab6fea187ac787a8e1a0e3df5ceef85a1ab9d58112a08bb7af40b1b3a3b00670431b0603510 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:2.3.4": version: 2.3.4 resolution: "@formatjs/ecma402-abstract@npm:2.3.4" @@ -2273,6 +2281,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:3.2.0": + version: 3.2.0 + resolution: "@formatjs/ecma402-abstract@npm:3.2.0" + dependencies: + "@formatjs/bigdecimal": "npm:0.2.0" + "@formatjs/fast-memoize": "npm:3.1.1" + "@formatjs/intl-localematcher": "npm:0.8.2" + checksum: 10c0/b3c8ac881c3d7533fb4127ca3d771d2a32cb89e6efbbcc72d80b1dcc6a798494ace9ca5ee822b25eb08ebdc7ee2885a9e33496a436b40271ffc915ece605a3ce + languageName: node + linkType: hard + "@formatjs/fast-memoize@npm:2.2.7": version: 2.2.7 resolution: "@formatjs/fast-memoize@npm:2.2.7" @@ -2282,6 +2301,13 @@ __metadata: languageName: node linkType: hard +"@formatjs/fast-memoize@npm:3.1.1": + version: 3.1.1 + resolution: "@formatjs/fast-memoize@npm:3.1.1" + checksum: 10c0/79b24dc1389a49b2b2fb9e90a2ba922a4057d4b74e7bc33a3811f0dc94a5a868d28e8e37917b68c2f831070d11dfd0889de686f269bf5214085a44efc1c25a8c + languageName: node + linkType: hard + "@formatjs/icu-messageformat-parser@npm:2.11.2": version: 2.11.2 resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2" @@ -2303,6 +2329,24 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-getcanonicallocales@npm:3.2.2": + version: 3.2.2 + resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.2" + checksum: 10c0/3b0235c0752a1db8d92502211d048822711d9141217f679f54a0914426e2516685b468f2711f0d27a18f3db3d1908c7e2655746af3fb34e660b1126450c3f42a + languageName: node + linkType: hard + +"@formatjs/intl-locale@npm:5.3.1": + version: 5.3.1 + resolution: "@formatjs/intl-locale@npm:5.3.1" + dependencies: + "@formatjs/ecma402-abstract": "npm:3.2.0" + "@formatjs/intl-getcanonicallocales": "npm:3.2.2" + "@formatjs/intl-supportedvaluesof": "npm:2.3.0" + checksum: 10c0/19e00dde293d2cfda7357420957062e319a041bc3aaa8bd9b834463074e6e7bf2ae2374818a01c24d659c4373b1954a671460299258568775d30875aabbeaf90 + languageName: node + linkType: hard + "@formatjs/intl-localematcher@npm:0.6.1": version: 0.6.1 resolution: "@formatjs/intl-localematcher@npm:0.6.1" @@ -2312,6 +2356,25 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-localematcher@npm:0.8.2": + version: 0.8.2 + resolution: "@formatjs/intl-localematcher@npm:0.8.2" + dependencies: + "@formatjs/fast-memoize": "npm:3.1.1" + checksum: 10c0/3bf838a018184837b167964849dafdcdeac95531a24f4df7d868638d4ad716854a250e9bccac9ab4568264c0db7470e70b99363da1db308fdc882b87f3eca651 + languageName: node + linkType: hard + +"@formatjs/intl-supportedvaluesof@npm:2.3.0": + version: 2.3.0 + resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.0" + dependencies: + "@formatjs/ecma402-abstract": "npm:3.2.0" + "@formatjs/fast-memoize": "npm:3.1.1" + checksum: 10c0/132a44cf6922f8eaa58fef47f74179d75614a9c68343defc07f6d42080e29205b2a8663db599e404da4dd657790aca0b2312733abfeec56d3524593d6e8a4f6e + languageName: node + linkType: hard + "@hapi/address@npm:^5.1.1": version: 5.1.1 resolution: "@hapi/address@npm:5.1.1"