From d36777a7db7c96964091ec449721074d9ac6e0ce Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 5 Feb 2026 16:03:43 -0500 Subject: [PATCH 1/2] test --- .github/actions/yarn/action.yml | 13 +- .github/workflows/test.yml | 17 ++ package.json | 12 +- packages/gamut-icons/package.json | 2 +- packages/gamut-illustrations/package.json | 4 +- packages/gamut-patterns/package.json | 4 +- packages/gamut-styles/package.json | 2 +- packages/gamut-tests/package.json | 2 +- packages/gamut/package.json | 4 +- packages/gamut/src/BarChart/utils/index.tsx | 270 ++++++++++++++++++++ 10 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 packages/gamut/src/BarChart/utils/index.tsx diff --git a/.github/actions/yarn/action.yml b/.github/actions/yarn/action.yml index 2aa211a518..814291b09d 100644 --- a/.github/actions/yarn/action.yml +++ b/.github/actions/yarn/action.yml @@ -1,6 +1,12 @@ name: 'Setup Node & yarn' description: 'Setup a NodeJS environment, install deps, & builds packages' +inputs: + immutable: + description: 'Run yarn install with --immutable (fail if lockfile would change)' + required: false + default: 'true' + runs: using: 'composite' steps: @@ -13,4 +19,9 @@ runs: shell: bash - name: Install dependencies shell: bash - run: yarn install --immutable + run: | + if [ "${{ inputs.immutable }}" = "true" ]; then + yarn install --immutable + else + yarn install + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3322ad9daf..a2362993f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,13 +20,30 @@ permissions: jobs: test: + name: Test (React ${{ matrix.react_version }}) runs-on: ubuntu-24.04 timeout-minutes: 30 + strategy: + matrix: + react_version: [18, 19] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + - name: Set React version + if: matrix.react_version == 18 + run: | + node -e " + const p = require('./package.json'); + p.resolutions['react'] = '18.3.1'; + p.resolutions['react-dom'] = '18.3.1'; + p.resolutions['@types/react'] = '18.3.1'; + p.resolutions['@types/react-dom'] = '18.3.0'; + require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2)); + " - uses: ./.github/actions/yarn + with: + immutable: ${{ matrix.react_version == 19 }} - name: Fetch main branch run: git fetch origin main:main if: github.event_name == 'pull_request' diff --git a/package.json b/package.json index 5579ff15cd..9bf1d8bfd3 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@types/invariant": "2.2.29", "@types/konami-code-js": "^0.8.0", "@types/lodash": "4.17.23", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@types/react-test-renderer": "^19.1.0", "@types/stylis": "^4.2.0", "@typescript-eslint/eslint-plugin": "^5.15.0", @@ -113,10 +113,10 @@ "repository": "git@github.com:Codecademy/gamut.git", "resolutions": { "@typescript-eslint/utils": "^5.15.0", - "@types/react": "18.3.1", - "@types/react-dom": "18.3.0", - "react": "18.3.1", - "react-dom": "18.3.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "error-ex": "1.3.4" }, "scripts": { diff --git a/packages/gamut-icons/package.json b/packages/gamut-icons/package.json index 28f3f47dad..fe7087b347 100644 --- a/packages/gamut-icons/package.json +++ b/packages/gamut-icons/package.json @@ -18,7 +18,7 @@ "@emotion/styled": "^11.3.0", "@types/react": "^18.0.0 || ^19.0.0", "lodash": "^4.17.5", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-illustrations/package.json b/packages/gamut-illustrations/package.json index d6a98ea6d7..492fc5d830 100644 --- a/packages/gamut-illustrations/package.json +++ b/packages/gamut-illustrations/package.json @@ -19,8 +19,8 @@ "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "@types/react": "^18.0.0 || ^19.0.0", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-patterns/package.json b/packages/gamut-patterns/package.json index 845a7356b5..579973866e 100644 --- a/packages/gamut-patterns/package.json +++ b/packages/gamut-patterns/package.json @@ -20,8 +20,8 @@ "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "@types/react": "^18.0.0 || ^19.0.0", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut-styles/package.json b/packages/gamut-styles/package.json index 8deacc9bb8..1f16ee8cfc 100644 --- a/packages/gamut-styles/package.json +++ b/packages/gamut-styles/package.json @@ -25,7 +25,7 @@ "@emotion/styled": "^11.3.0", "@types/react": "^18.0.0 || ^19.0.0", "lodash": "^4.17.5", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react": "^18.2.0 || ^19.0.0", "stylis": "^4.0.7" }, "publishConfig": { diff --git a/packages/gamut-tests/package.json b/packages/gamut-tests/package.json index 4177bd02f7..c8bd24d90e 100644 --- a/packages/gamut-tests/package.json +++ b/packages/gamut-tests/package.json @@ -23,7 +23,7 @@ "module": "dist/index.js", "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 62fa61feda..64c5623012 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -38,8 +38,8 @@ "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "@types/react": "^18.0.0 || ^19.0.0", - "react": "^17.0.2 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + "react": "^18.2.0 || ^19.0.0", + "react-dom": "^18.2.0 || ^19.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/gamut/src/BarChart/utils/index.tsx b/packages/gamut/src/BarChart/utils/index.tsx new file mode 100644 index 0000000000..c6d390611e --- /dev/null +++ b/packages/gamut/src/BarChart/utils/index.tsx @@ -0,0 +1,270 @@ +import { BarChartTranslations } from '../shared/translations'; +import { BarChartRange, BarChartUnit, BarProps } from '../shared/types'; + +export const numDigits = ({ num }: { num: number }) => { + return Math.max(Math.floor(Math.log10(Math.abs(num))), 0) + 1; +}; + +export const columnBaseSize = ({ experience = 3 }: { experience?: number }) => { + const digits = numDigits({ num: experience }); + return { + sm: digits > 4 ? 5 : 4, + md: digits > 4 ? 5 : 4, + lg: digits > 4 ? 4 : 5, + xl: digits > 4 ? 5 : 4, + }; +}; + +export const calculatePercent = ({ + value, + total, +}: { + value: number; + total: number; +}) => { + return (value / total) * 100; +}; + +export const calculateBarWidth = ({ + value, + minRange, + maxRange, +}: { + value: number; +} & BarChartRange) => { + const range = maxRange - minRange; + const adjustedValue = Math.max(0, Math.min(range, value - minRange)); + return Math.floor(calculatePercent({ value: adjustedValue, total: range })); +}; + +/** + * Calculate tick spacing and nice minimum and maximum data points on the axis. + */ +export const calculateTicksAndRange = ({ + maxTicks, + min, + max, +}: { + maxTicks: number; +} & { + min: BarChartRange['minRange']; + max: BarChartRange['maxRange']; +}): [number, number, number] => { + const range = niceNum({ range: max - min, roundDown: false }); + const tickSpacing = niceNum({ + range: range / (maxTicks - 1), + roundDown: true, + }); + const niceMin = Math.floor(min / tickSpacing) * tickSpacing; + const niceMax = Math.ceil(max / tickSpacing) * tickSpacing; + const tickCount = range / tickSpacing; + return [tickCount, niceMin, niceMax]; +}; + +/** + * Returns a "nice" number approximately equal to range + * Rounds the number if round = true + * Takes the ceiling if round = false. + * A nice number is a simple decimal number, for example if a number is 1234, a nice number would be 1000 or 2000. + */ +export const niceNum = ({ + range, + roundDown, +}: { + range: number; + roundDown: boolean; +}): number => { + const exponent = Math.floor(Math.log10(range)); + const fraction = range / 10 ** exponent; + + let niceFraction: number; + + if (roundDown) { + if (fraction >= 10) niceFraction = 10; + else if (fraction >= 5) niceFraction = 5; + else if (fraction >= 2) niceFraction = 2; + else if (fraction >= 1) niceFraction = 1; + else niceFraction = 1; + } else if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 2; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + + return niceFraction * 10 ** exponent; +}; + +export const getPercentDiff = ({ v1, v2 }: { v1: number; v2: number }) => { + return (Math.abs(v1 - v2) / ((v1 + v2) / 2)) * 100; +}; + +export const formatNumberUS = ({ + num, + locale = 'en', +}: { + num: number; + locale?: string; +}) => Intl.NumberFormat(locale).format(num); + +/** + * Formats a numeric value with optional unit for bar chart labels. + */ +export const formatValueWithUnit = ({ + value, + unit, + locale = 'en', +}: { + value: number; + unit: string; + locale?: string; +}): string => { + const formatted = Intl.NumberFormat(locale).format(value); + return unit ? `${formatted} ${unit}` : formatted; +}; + +export const formatNumberUSCompact = ({ + num, + locale = 'en', +}: { + num: number; + locale?: string; +}) => + Intl.NumberFormat(locale, { + notation: 'compact', + compactDisplay: 'short', + }).format(num); + +/** + * Sort bars based on sortBy and order configuration + */ +export const sortBars = ({ + bars, + sortBy, + order, +}: { + bars: T[]; + sortBy: 'label' | 'value' | 'none'; + order: 'ascending' | 'descending'; +}): T[] => { + if (sortBy === 'none' || !sortBy) { + return bars; + } + + const sorted = [...bars].sort((a, b) => { + if (sortBy === 'label') { + return a.yLabel.localeCompare(b.yLabel); + } + // sortBy === 'value' - use seriesTwoValue if available, otherwise seriesOneValue + const aValue = a.seriesTwoValue ?? a.seriesOneValue; + const bValue = b.seriesTwoValue ?? b.seriesOneValue; + return aValue - bValue; + }); + + return order === 'descending' ? sorted.reverse() : sorted; +}; + +/** + * Generates an accessible summary of the bar values for aria-label / screen reader. + * When translations.accessibility keys are functions, they are called with scoped + * context (values, label, unit, locale) and their return value is used as the full summary. + */ +export const getValuesSummary = ({ + isInteractive, + seriesOneValue, + seriesTwoValue, + unit, + yLabel, + translations, +}: Pick & + Required> & { + isInteractive: boolean; + translations: BarChartTranslations; + }): string => { + const unitText = unit ? ` ${unit}` : ''; + const { locale } = translations; + + if (seriesTwoValue !== undefined) { + const { gainedNowAt, inLabel } = translations.accessibility; + if (typeof gainedNowAt === 'function') { + return gainedNowAt({ + yLabel, + seriesOneValue, + seriesTwoValue, + unit, + locale, + }); + } + const gained = seriesOneValue; + return `${gained}${unitText} ${gainedNowAt} ${seriesTwoValue}${unitText} ${inLabel} ${yLabel}`; + } + + if (isInteractive) { + const { inLabel } = translations.accessibility; + if (typeof inLabel === 'function') { + return inLabel({ + yLabel, + value: seriesOneValue, + unit, + locale, + }); + } + return `${seriesOneValue}${unitText} ${inLabel} ${yLabel}`; + } + + const { inOnly } = translations.accessibility; + if (typeof inOnly === 'function') { + return inOnly({ + value: seriesOneValue, + unit, + locale, + }); + } + return `${seriesOneValue}${unitText} ${inOnly}`; +}; + +/** + * Calculates the value for a given label position + */ +export const getLabel = ({ + labelCount, + labelIndex, + min, + max, +}: { + labelCount: number; + labelIndex: number; +} & { + min: BarChartRange['minRange']; + max: BarChartRange['maxRange']; +}): number => { + if (labelCount <= 1) return max; + const incrementalDecimal = labelIndex / (labelCount - 1); + return Math.floor(min + incrementalDecimal * (max - min)); +}; + +/** + * Calculates the percentage position for a given value within a range + * Returns a value between 0 and 100 representing the position percentage + */ +export const calculatePositionPercent = ({ + value, + min, + max, +}: { + value: number; +} & { + min: BarChartRange['minRange']; + max: BarChartRange['maxRange']; +}): number => { + if (max === min) return 0; + const range = max - min; + const adjustedValue = value - min; + return (adjustedValue / range) * 100; +}; + +export const getBarRowKey = ( + bar: Pick, + index: number +): string => + bar.yLabel && bar.seriesOneValue + ? `${bar.yLabel}-${bar.seriesOneValue}-${bar.seriesTwoValue ?? ''}-${index}` + : String(index); From 3a6ef7ecf7a3318180b4286d959037b5e6755a25 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 5 Feb 2026 16:05:34 -0500 Subject: [PATCH 2/2] accidntal add --- packages/gamut/src/BarChart/utils/index.tsx | 270 -------------------- 1 file changed, 270 deletions(-) delete mode 100644 packages/gamut/src/BarChart/utils/index.tsx diff --git a/packages/gamut/src/BarChart/utils/index.tsx b/packages/gamut/src/BarChart/utils/index.tsx deleted file mode 100644 index c6d390611e..0000000000 --- a/packages/gamut/src/BarChart/utils/index.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { BarChartTranslations } from '../shared/translations'; -import { BarChartRange, BarChartUnit, BarProps } from '../shared/types'; - -export const numDigits = ({ num }: { num: number }) => { - return Math.max(Math.floor(Math.log10(Math.abs(num))), 0) + 1; -}; - -export const columnBaseSize = ({ experience = 3 }: { experience?: number }) => { - const digits = numDigits({ num: experience }); - return { - sm: digits > 4 ? 5 : 4, - md: digits > 4 ? 5 : 4, - lg: digits > 4 ? 4 : 5, - xl: digits > 4 ? 5 : 4, - }; -}; - -export const calculatePercent = ({ - value, - total, -}: { - value: number; - total: number; -}) => { - return (value / total) * 100; -}; - -export const calculateBarWidth = ({ - value, - minRange, - maxRange, -}: { - value: number; -} & BarChartRange) => { - const range = maxRange - minRange; - const adjustedValue = Math.max(0, Math.min(range, value - minRange)); - return Math.floor(calculatePercent({ value: adjustedValue, total: range })); -}; - -/** - * Calculate tick spacing and nice minimum and maximum data points on the axis. - */ -export const calculateTicksAndRange = ({ - maxTicks, - min, - max, -}: { - maxTicks: number; -} & { - min: BarChartRange['minRange']; - max: BarChartRange['maxRange']; -}): [number, number, number] => { - const range = niceNum({ range: max - min, roundDown: false }); - const tickSpacing = niceNum({ - range: range / (maxTicks - 1), - roundDown: true, - }); - const niceMin = Math.floor(min / tickSpacing) * tickSpacing; - const niceMax = Math.ceil(max / tickSpacing) * tickSpacing; - const tickCount = range / tickSpacing; - return [tickCount, niceMin, niceMax]; -}; - -/** - * Returns a "nice" number approximately equal to range - * Rounds the number if round = true - * Takes the ceiling if round = false. - * A nice number is a simple decimal number, for example if a number is 1234, a nice number would be 1000 or 2000. - */ -export const niceNum = ({ - range, - roundDown, -}: { - range: number; - roundDown: boolean; -}): number => { - const exponent = Math.floor(Math.log10(range)); - const fraction = range / 10 ** exponent; - - let niceFraction: number; - - if (roundDown) { - if (fraction >= 10) niceFraction = 10; - else if (fraction >= 5) niceFraction = 5; - else if (fraction >= 2) niceFraction = 2; - else if (fraction >= 1) niceFraction = 1; - else niceFraction = 1; - } else if (fraction <= 1) niceFraction = 1; - else if (fraction <= 2) niceFraction = 2; - else if (fraction <= 5) niceFraction = 5; - else niceFraction = 10; - - return niceFraction * 10 ** exponent; -}; - -export const getPercentDiff = ({ v1, v2 }: { v1: number; v2: number }) => { - return (Math.abs(v1 - v2) / ((v1 + v2) / 2)) * 100; -}; - -export const formatNumberUS = ({ - num, - locale = 'en', -}: { - num: number; - locale?: string; -}) => Intl.NumberFormat(locale).format(num); - -/** - * Formats a numeric value with optional unit for bar chart labels. - */ -export const formatValueWithUnit = ({ - value, - unit, - locale = 'en', -}: { - value: number; - unit: string; - locale?: string; -}): string => { - const formatted = Intl.NumberFormat(locale).format(value); - return unit ? `${formatted} ${unit}` : formatted; -}; - -export const formatNumberUSCompact = ({ - num, - locale = 'en', -}: { - num: number; - locale?: string; -}) => - Intl.NumberFormat(locale, { - notation: 'compact', - compactDisplay: 'short', - }).format(num); - -/** - * Sort bars based on sortBy and order configuration - */ -export const sortBars = ({ - bars, - sortBy, - order, -}: { - bars: T[]; - sortBy: 'label' | 'value' | 'none'; - order: 'ascending' | 'descending'; -}): T[] => { - if (sortBy === 'none' || !sortBy) { - return bars; - } - - const sorted = [...bars].sort((a, b) => { - if (sortBy === 'label') { - return a.yLabel.localeCompare(b.yLabel); - } - // sortBy === 'value' - use seriesTwoValue if available, otherwise seriesOneValue - const aValue = a.seriesTwoValue ?? a.seriesOneValue; - const bValue = b.seriesTwoValue ?? b.seriesOneValue; - return aValue - bValue; - }); - - return order === 'descending' ? sorted.reverse() : sorted; -}; - -/** - * Generates an accessible summary of the bar values for aria-label / screen reader. - * When translations.accessibility keys are functions, they are called with scoped - * context (values, label, unit, locale) and their return value is used as the full summary. - */ -export const getValuesSummary = ({ - isInteractive, - seriesOneValue, - seriesTwoValue, - unit, - yLabel, - translations, -}: Pick & - Required> & { - isInteractive: boolean; - translations: BarChartTranslations; - }): string => { - const unitText = unit ? ` ${unit}` : ''; - const { locale } = translations; - - if (seriesTwoValue !== undefined) { - const { gainedNowAt, inLabel } = translations.accessibility; - if (typeof gainedNowAt === 'function') { - return gainedNowAt({ - yLabel, - seriesOneValue, - seriesTwoValue, - unit, - locale, - }); - } - const gained = seriesOneValue; - return `${gained}${unitText} ${gainedNowAt} ${seriesTwoValue}${unitText} ${inLabel} ${yLabel}`; - } - - if (isInteractive) { - const { inLabel } = translations.accessibility; - if (typeof inLabel === 'function') { - return inLabel({ - yLabel, - value: seriesOneValue, - unit, - locale, - }); - } - return `${seriesOneValue}${unitText} ${inLabel} ${yLabel}`; - } - - const { inOnly } = translations.accessibility; - if (typeof inOnly === 'function') { - return inOnly({ - value: seriesOneValue, - unit, - locale, - }); - } - return `${seriesOneValue}${unitText} ${inOnly}`; -}; - -/** - * Calculates the value for a given label position - */ -export const getLabel = ({ - labelCount, - labelIndex, - min, - max, -}: { - labelCount: number; - labelIndex: number; -} & { - min: BarChartRange['minRange']; - max: BarChartRange['maxRange']; -}): number => { - if (labelCount <= 1) return max; - const incrementalDecimal = labelIndex / (labelCount - 1); - return Math.floor(min + incrementalDecimal * (max - min)); -}; - -/** - * Calculates the percentage position for a given value within a range - * Returns a value between 0 and 100 representing the position percentage - */ -export const calculatePositionPercent = ({ - value, - min, - max, -}: { - value: number; -} & { - min: BarChartRange['minRange']; - max: BarChartRange['maxRange']; -}): number => { - if (max === min) return 0; - const range = max - min; - const adjustedValue = value - min; - return (adjustedValue / range) * 100; -}; - -export const getBarRowKey = ( - bar: Pick, - index: number -): string => - bar.yLabel && bar.seriesOneValue - ? `${bar.yLabel}-${bar.seriesOneValue}-${bar.seriesTwoValue ?? ''}-${index}` - : String(index);