diff --git a/.changeset/thin-gifts-itch.md b/.changeset/thin-gifts-itch.md new file mode 100644 index 0000000000..762dfcaa09 --- /dev/null +++ b/.changeset/thin-gifts-itch.md @@ -0,0 +1,5 @@ +--- +"@ultraviolet/ui": patch +--- + +fix checkbox event on List & Table component diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99cb5b063c..037e72d8ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,36 +9,56 @@ on: - main jobs: + build: + strategy: + matrix: + node: ["22"] + runs-on: ubuntu-24.04 + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_CACHE: "remote:rw" + steps: + - uses: actions/checkout@v4 # v4.1.4 + - uses: pnpm/action-setup@v4.0.0 + - uses: actions/setup-node@v4.1.0 + with: + node-version: ${{ matrix.node }} + - name: build + run: | + pnpm install --frozen-lockfile + pnpm run build manypkg: runs-on: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: 22 cache: "pnpm" - - run: | + - name: manypkg + run: | pnpm install --frozen-lockfile pnpm exec manypkg check + typecheck: runs-on: ubuntu-24.04 + needs: [build] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: 22 cache: "pnpm" - - run: | + - name: typecheck + run: | pnpm install --frozen-lockfile pnpm typecheck @@ -46,48 +66,47 @@ jobs: runs-on: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: 22 cache: "pnpm" - - run: | + - name: oxlint + run: | pnpm install --frozen-lockfile - pnpm build pnpm oxlint -c .oxlintrc.json --quiet lint: runs-on: ubuntu-24.04 + needs: [build] env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: 22 cache: "pnpm" - - run: | + - name: lint + run: | pnpm install --frozen-lockfile - pnpm build + pnpm run build pnpm run lint format: runs-on: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: 22 cache: "pnpm" @@ -99,18 +118,17 @@ jobs: runs-on: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" needs: [typecheck, format] strategy: matrix: - node: ["22"] + node: ["20", "22"] steps: - uses: actions/checkout@v4 with: fetch-depth: 10 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v4.1.0 with: node-version: ${{ matrix.node }} cache: "pnpm" @@ -139,35 +157,16 @@ jobs: # - run: pnpm run build # - run: pnpm install # - run: pnpm run test:a11y - build: - strategy: - matrix: - node: ["22"] - runs-on: ubuntu-24.04 - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true - needs: [typecheck, format] - steps: - - uses: actions/checkout@v4 # v4.1.4 - - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js - uses: actions/setup-node@v4.1.0 - with: - node-version: ${{ matrix.node }} - - run: | - pnpm install --frozen-lockfile - pnpm run build publint: runs-on: ubuntu-24.04 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true + TURBO_CACHE: "remote:rw" steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4.0.0 - - name: Use Node.js + - name: publint uses: actions/setup-node@v4.1.0 with: node-version: 22 @@ -178,12 +177,15 @@ jobs: deploy: runs-on: ubuntu-24.04 + needs: [publint, typecheck, build] env: IMAGE_NAME: rg.fr-par.scw.cloud/ultraviolet/storybook DEPLOYMENT_NAME: "storybook" TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_ONLY: true - needs: [publint, typecheck, build] + TURBO_CACHE: "remote:rw" + # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#example-usage-of-the-jobs-context + outputs: + base_url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v4 - name: Inject slug/short variables @@ -296,3 +298,31 @@ jobs: run: | rm -rf /tmp/docs/.buildx-cache mv /tmp/docs/.buildx-cache-new /tmp/docs/.buildx-cache + + # e2e: + # timeout-minutes: 5 + # runs-on: ubuntu-latest + # needs: [deploy] + # env: + # CI: true + # TURBO_CACHE: "remote:rw" + # BASE_URL: ${{ needs.deploy.outputs.base_url }} + # steps: + # - uses: actions/checkout@v4 + # - uses: pnpm/action-setup@v4.0.0 + # - uses: actions/setup-node@v4.1.0 + # with: + # node-version: 22 + # cache: "pnpm" + # - name: install pnpm deps + # run: | + # pnpm install --frozen-lockfile + # pnpm exec playwright install --with-deps + # - name: run e2e + # run: pnpm run test:e2e + # - uses: actions/upload-artifact@v4 + # if: ${{ !cancelled() }} + # with: + # name: playwright-report + # path: playwright-report/ + # retention-days: 5 diff --git a/.gitignore b/.gitignore index 31dce8deab..4880e961e0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ tools/*/.turbo # next next-env.d.ts* +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000000..ce481616fc --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,43 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig, devices } from '@playwright/test' + +const isCI = process.env['CI'] + +const baseURL = process.env['BASE_URL'] ?? 'http://localhost:6006' + +const times = { + '1min': 60 * 1000, + '3min': 3 * 60 * 1000, +} + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: 'html', + globalTimeout: isCI ? 5 * times['1min'] : undefined, + timeout: isCI ? 1 * times['1min'] : undefined, + use: { + baseURL, + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}) diff --git a/e2e/tests/default.spec.ts b/e2e/tests/default.spec.ts new file mode 100644 index 0000000000..8526507500 --- /dev/null +++ b/e2e/tests/default.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test' + +test.describe('Default', () => { + test('title', async ({ page, baseURL }) => { + await page.goto(`${baseURL}`) + + await expect(page).toHaveTitle('Get started - Docs ⋅ Storybook') + }) +}) diff --git a/e2e/tests/ui/checkbox.spec.ts b/e2e/tests/ui/checkbox.spec.ts new file mode 100644 index 0000000000..c4ccaeeefe --- /dev/null +++ b/e2e/tests/ui/checkbox.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' + +const defaultLocator = 'iframe[title="storybook-preview-iframe"]' + +const defaultURL = `/?path=/story/components-data-entry-checkbox--playground` + +test.describe('List', () => { + test('Checkbox Row', async ({ page, baseURL }) => { + const url = `${baseURL}${defaultURL}` + + await page.goto(url) + + const rootLocator = page.locator(defaultLocator).contentFrame() + await rootLocator.getByRole('checkbox').click() + + const checkbox = rootLocator.locator(`input[type='checkbox']`) + + await expect(checkbox).toBeChecked({ + checked: true, + }) + }) +}) diff --git a/e2e/tests/ui/list.spec.ts b/e2e/tests/ui/list.spec.ts new file mode 100644 index 0000000000..ec8e6d23b7 --- /dev/null +++ b/e2e/tests/ui/list.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' + +const defaultLocator = 'iframe[title="storybook-preview-iframe"]' +const defaultURL = `/?path=/story/components-data-display-list--selectable` + +test.describe('List', () => { + test('Checkbox Row', async ({ page, baseURL }) => { + const url = `${baseURL}${defaultURL}` + + await page.goto(url) + + const rootLocator = page.locator(defaultLocator).contentFrame() + await rootLocator + .getByRole('row', { name: 'select Venus 0.718AU 0.728AU' }) + .getByLabel('select') + .check() + + const checkbox = rootLocator.locator( + `input[type='checkbox'][name='list-select-checkbox'][value="venus"]`, + ) + + await expect(checkbox).toBeChecked({ + checked: true, + }) + }) +}) diff --git a/package.json b/package.json index 64d031c5fd..0d0607d15b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "build:storybook:stats": "pnpm turbo build:storybook -- --webpack-stats-json", "test:unit": "turbo run test:unit", "test:unit:coverage": "turbo run test:unit:coverage", + "test:e2e": "playwright test -c e2e", + "test:e2e:headed": "pnpm run test:e2e --headed", + "test:e2e:debug": "PWDEBUG=1 pnpm run test:e2e", "check:deps": "npx depcheck . --skip-missing=true --ignores='bin,eslint,vite,jest,husky,@commitlint/*,@babel/*,babel-*'", "commit": "npx git-cz -a --disable-emoji", "start": "STORYBOOK_ENVIRONMENT=development storybook dev -p 6006", @@ -110,6 +113,7 @@ "@eslint/compat": "1.2.4", "@eslint/eslintrc": "3.2.0", "@manypkg/cli": "0.23.0", + "@playwright/test": "1.49.1", "@scaleway/eslint-config-react": "4.0.9", "@scaleway/tsconfig": "1.1.1", "@size-limit/file": "11.1.6", diff --git a/packages/ui/src/components/Checkbox/__stories__/Template.stories.tsx b/packages/ui/src/components/Checkbox/__stories__/Template.stories.tsx index ba956f9557..8ec777328d 100644 --- a/packages/ui/src/components/Checkbox/__stories__/Template.stories.tsx +++ b/packages/ui/src/components/Checkbox/__stories__/Template.stories.tsx @@ -1,11 +1,26 @@ import type { StoryFn } from '@storybook/react' +import { useState } from 'react' import { Checkbox } from '..' export const Template: StoryFn = ({ 'aria-label': ariaLabel, ...args -}) => ( - - Beautiful checkbox - -) +}) => { + const [checked, setChecked] = useState(false) + + return ( + setChecked(e.target.checked)} + {...args} + > + Beautiful checkbox + + ) +} + +Template.args = { + disabled: false, + name: 'basic', + value: 'label-1', +} diff --git a/packages/ui/src/components/Checkbox/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/Checkbox/__tests__/__snapshots__/index.test.tsx.snap index 063c0b85ba..6a3816c556 100644 --- a/packages/ui/src/components/Checkbox/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/Checkbox/__tests__/__snapshots__/index.test.tsx.snap @@ -4906,7 +4906,7 @@ exports[`Checkbox > renders correctly with progress 1`] = ` data-testid="testing" >
renders correctly with progress 1`] = ` aria-checked="false" aria-invalid="false" class="emotion-7 emotion-8" + disabled="" id=":r15:" type="checkbox" /> @@ -5245,7 +5246,7 @@ exports[`Checkbox > renders correctly with progress and no child 1`] = ` data-testid="testing" >
renders correctly with progress and no child 1`] = ` aria-invalid="false" aria-label="check" class="emotion-7 emotion-8" + disabled="" id=":r18:" type="checkbox" /> @@ -5674,7 +5676,7 @@ exports[`Checkbox > renders correctly with sizes 1`] = `
renders correctly with sizes 1`] = ` aria-checked="false" aria-invalid="false" class="emotion-2 emotion-3" + disabled="" id=":r1g:" type="checkbox" value="test" diff --git a/packages/ui/src/components/Checkbox/__tests__/index.test.tsx b/packages/ui/src/components/Checkbox/__tests__/index.test.tsx index 8d92c10fcb..b3b5fe7f56 100644 --- a/packages/ui/src/components/Checkbox/__tests__/index.test.tsx +++ b/packages/ui/src/components/Checkbox/__tests__/index.test.tsx @@ -1,10 +1,24 @@ import { screen } from '@testing-library/react' import { userEvent } from '@testing-library/user-event' import { renderWithTheme, shouldMatchEmotionSnapshot } from '@utils/test' +import { useReducer } from 'react' +import type { ActionDispatch } from 'react' import { describe, expect, test } from 'vitest' import { Checkbox } from '..' +type ChildrenProps = { checked: boolean; onChange: ActionDispatch<[]> } +type Props = { + defaultChecked?: boolean + children: (options: ChildrenProps) => React.ReactNode +} + describe('Checkbox', () => { + const LocalControlValue = ({ defaultChecked = false, children }: Props) => { + const [checked, setCheked] = useReducer(check => !check, defaultChecked) + + return children({ checked, onChange: setCheked }) + } + test('renders correctly', () => shouldMatchEmotionSnapshot( { test('renders with click event', async () => { renderWithTheme( - {}} size={37} value="test"> - Checkbox Label - , + + {({ checked, onChange }) => ( + + Checkbox Label + + )} + , ) const input = screen.getByRole('checkbox', { @@ -145,9 +168,19 @@ describe('Checkbox', () => { test('renders with click event with progress', async () => { renderWithTheme( - {}} size={37} value="test" progress> - Checkbox Label - , + + {({ checked, onChange }) => ( + + Checkbox Label + + )} + , ) const input = screen.getByRole('checkbox', { @@ -155,5 +188,8 @@ describe('Checkbox', () => { }) await userEvent.click(input) expect(input.checked).toBeTruthy() + // checked value cannot change during progress/loading + await userEvent.click(input) + expect(input.checked).toBeTruthy() }) }) diff --git a/packages/ui/src/components/Checkbox/index.tsx b/packages/ui/src/components/Checkbox/index.tsx index bc42cd6725..35fec24e12 100644 --- a/packages/ui/src/components/Checkbox/index.tsx +++ b/packages/ui/src/components/Checkbox/index.tsx @@ -1,13 +1,8 @@ import { useTheme } from '@emotion/react' import styled from '@emotion/styled' import { AsteriskIcon } from '@ultraviolet/icons' -import type { - ChangeEvent, - ForwardedRef, - InputHTMLAttributes, - ReactNode, -} from 'react' -import { forwardRef, useCallback, useEffect, useId, useState } from 'react' +import type { InputHTMLAttributes, ReactNode } from 'react' +import { forwardRef, useId } from 'react' import { Loader } from '../Loader' import { Stack } from '../Stack' import { Text } from '../Text' @@ -294,21 +289,22 @@ type CheckboxProps = { tooltip?: string } & Pick< InputHTMLAttributes, - | 'onFocus' - | 'onBlur' - | 'name' - | 'value' | 'autoFocus' | 'id' + | 'name' + | 'onBlur' | 'onChange' + | 'onClick' + | 'onFocus' | 'tabIndex' + | 'value' > & LabelProp /** * Checkbox is an input component used to select or deselect an option. */ -export const Checkbox = forwardRef( +export const Checkbox = forwardRef( ( { id, @@ -332,35 +328,23 @@ export const Checkbox = forwardRef( 'data-testid': dataTestId, tooltip, tabIndex, - }: CheckboxProps, - ref: ForwardedRef, + }, + ref, ) => { const theme = useTheme() - const [state, setState] = useState(checked) const uniqId = useId() const localId = id ?? uniqId - useEffect(() => { - setState(checked) - }, [checked]) - - const onLocalChange = useCallback( - (event: ChangeEvent) => { - if (!progress) onChange?.(event) - setState(current => - current === 'indeterminate' ? false : event.target.checked, - ) - }, - [onChange, progress, setState], - ) + const isDisabled = disabled || progress + const isCheck = checked === true ? checked : false return ( @@ -369,19 +353,20 @@ export const Checkbox = forwardRef( ) : null} + - {state !== 'indeterminate' ? ( + {checked !== 'indeterminate' ? ( ( ( - { - children, - className, - preventClick, - 'data-testid': dataTestid, - colSpan, - }: CellProps, - ref: ForwardedRef, + { children, className, preventClick, 'data-testid': dataTestid, colSpan }, + ref, ) => { const handleClick: MouseEventHandler = event => { if (preventClick) { diff --git a/packages/ui/src/components/List/HeaderRow.tsx b/packages/ui/src/components/List/HeaderRow.tsx index 19d2a97927..862e5fd7d5 100644 --- a/packages/ui/src/components/List/HeaderRow.tsx +++ b/packages/ui/src/components/List/HeaderRow.tsx @@ -43,13 +43,8 @@ type RowProps = { } export const HeaderRow = ({ children, hasSelectAllColumn }: RowProps) => { - const { - allRowSelectValue, - selectAll, - unselectAll, - selectedRowIds, - expandButton, - } = useListContext() + const { allRowSelectValue, selectAllHandler, selectedRowIds, expandButton } = + useListContext() const selectableRowCount = Object.keys(selectedRowIds).length @@ -63,7 +58,7 @@ export const HeaderRow = ({ children, hasSelectAllColumn }: RowProps) => { value="all" aria-label="select all" checked={allRowSelectValue} - onChange={allRowSelectValue === false ? selectAll : unselectAll} + onChange={selectAllHandler} disabled={selectableRowCount === 0} /> diff --git a/packages/ui/src/components/List/ListContext.tsx b/packages/ui/src/components/List/ListContext.tsx index 45628167c2..be0021e5f9 100644 --- a/packages/ui/src/components/List/ListContext.tsx +++ b/packages/ui/src/components/List/ListContext.tsx @@ -1,10 +1,3 @@ -import type { - ComponentProps, - Dispatch, - ReactNode, - RefObject, - SetStateAction, -} from 'react' import { createContext, useCallback, @@ -14,12 +7,20 @@ import { useRef, useState, } from 'react' +import type { + ChangeEvent, + ComponentProps, + Dispatch, + ReactNode, + SetStateAction, +} from 'react' import type { Checkbox } from '../Checkbox' import type { ColumnProps } from './types' -type RowState = Record +type RowState = Record +type MapCheckbox = Map -type ListContextValue = { +export type ListContextValue = { // ============ Expandable logic ============ /** * @returns an unregister function @@ -34,21 +35,23 @@ type ListContextValue = { * @returns an unregister function * */ registerSelectableRow: (rowId: string) => () => void - selectedRowIds: RowState - selectRow: (rowId: string) => void - unselectRow: (rowId: string) => void - selectable: boolean allRowSelectValue: ComponentProps['checked'] + selectAllHandler: (event: ChangeEvent) => void + subscribeHandler: () => void + columns: ColumnProps[] + inRange: Set + mapCheckbox: MapCheckbox + selectable: boolean selectAll: () => void + selectedRowIds: RowState + selectRow: (rowId: string) => void unselectAll: () => void - refList: RefObject - inRange: string[] - columns: ColumnProps[] + unselectRow: (rowId: string) => void } const ListContext = createContext(undefined) -type ListProviderProps = { +export type ListProviderProps = { children: ReactNode autoCollapse: boolean selectable: boolean @@ -57,6 +60,22 @@ type ListProviderProps = { columns: ColumnProps[] } +const checkStateOfCheckboxs = (ids: RowState) => { + const selectableRowCount = Object.keys(ids).length + const selectableValuesCount = new Set(Object.values(ids)) + + if (!selectableRowCount) { + return false + } + + // if there is one value it's meant that all checkboxes are only true or false + if (selectableValuesCount.size === 1) { + return [...selectableValuesCount][0] + } + + return 'indeterminate' +} + export const ListProvider = ({ children, autoCollapse, @@ -67,7 +86,11 @@ export const ListProvider = ({ }: ListProviderProps) => { const [expandedRowIds, setExpandedRowIds] = useState({}) const [selectedRowIds, setSelectedRowIds] = useState({}) - const refList = useRef([]) + const [lastCheckedIndex, setLastCheckedIndex] = useState< + null | (number | string) + >(null) + const [inRange, setInRange] = useState>(new Set([])) + const refList = useRef(new Map()) const registerExpandableRow = useCallback( (rowId: string, expanded = false) => { @@ -113,58 +136,16 @@ export const ListProvider = ({ })) }, []) - const allRowSelectValue = useMemo< - ComponentProps['checked'] - >(() => { - const selectableRowCount = Object.keys(selectedRowIds).length - if (!selectableRowCount) { - return false - } - - const selectedRowCount = Object.values(selectedRowIds).reduce( - (acc, isSelected) => acc + (isSelected ? 1 : 0), - 0, - ) - if (selectedRowCount === 0) { - return false - } - if (selectableRowCount === selectedRowCount) { - return true - } - - return 'indeterminate' - }, [selectedRowIds]) - - const selectAll = useCallback(() => { - const newSelectedRowIds = Object.keys(selectedRowIds).reduce< - typeof selectedRowIds - >((acc, rowId) => ({ ...acc, [rowId]: true }), {}) - setSelectedRowIds(newSelectedRowIds) - if (onSelectedChange) { - onSelectedChange( - Object.keys(newSelectedRowIds).filter(row => newSelectedRowIds[row]), - ) - } - }, [onSelectedChange, selectedRowIds]) - - const unselectAll = useCallback(() => { - const newSelectedRowIds = Object.keys(selectedRowIds).reduce< - typeof selectedRowIds - >((acc, rowId) => ({ ...acc, [rowId]: false }), {}) - setSelectedRowIds(newSelectedRowIds) - if (onSelectedChange) { - onSelectedChange( - Object.keys(newSelectedRowIds).filter(row => newSelectedRowIds[row]), + const allRowSelectValue = useMemo['checked']>( + () => checkStateOfCheckboxs(selectedRowIds), + [selectedRowIds], + ) + const selectRows = useCallback( + (rowIds: string[], state: boolean) => { + const newSelectedRowIds = rowIds.reduce( + (acc, rowId) => ({ ...acc, [rowId]: state }), + selectedRowIds, ) - } - }, [onSelectedChange, selectedRowIds]) - - const selectRow = useCallback( - (rowId: string) => { - const newSelectedRowIds = { - ...selectedRowIds, - [rowId]: true, - } setSelectedRowIds(newSelectedRowIds) if (onSelectedChange) { onSelectedChange( @@ -175,110 +156,128 @@ export const ListProvider = ({ [onSelectedChange, selectedRowIds], ) + const selectAll = useCallback(() => { + selectRows(Object.keys(selectedRowIds), true) + }, [selectRows, selectedRowIds]) + + const unselectAll = useCallback(() => { + selectRows(Object.keys(selectedRowIds), false) + }, [selectRows, selectedRowIds]) + + const selectRow = useCallback( + (rowId: string) => { + selectRows([rowId], true) + }, + [selectRows], + ) + const unselectRow = useCallback( (rowId: string) => { - const newSelectedRowIds = { - ...selectedRowIds, - [rowId]: false, - } - setSelectedRowIds(newSelectedRowIds) - if (onSelectedChange) { - onSelectedChange( - Object.keys(newSelectedRowIds).filter(row => newSelectedRowIds[row]), - ) - } + selectRows([rowId], false) }, - [onSelectedChange, selectedRowIds], + [selectRows], ) - const [lastCheckedIndex, setLastCheckedIndex] = useState(null) - const [inRange, setInRange] = useState([]) + const selectAllHandler: ListContextValue['selectAllHandler'] = + useCallback(() => { + // we choose to unselect all when checkbox is in indeterminate state + if ( + allRowSelectValue && + [true, 'indeterminate'].includes(allRowSelectValue) + ) { + unselectAll() + } + if (allRowSelectValue === false) { + selectAll() + } + }, [allRowSelectValue, unselectAll, selectAll]) - useEffect(() => { + const subscribeHandler = useCallback(() => { const handlers: (() => void)[] = [] + if (refList.current) { - const handleClick = ( - index: number, - isShiftPressed: boolean, - checked: boolean, - ) => { - if (index !== 0) { - setLastCheckedIndex(index) - if (isShiftPressed && lastCheckedIndex !== null) { - const start = Math.min(lastCheckedIndex, index) - const end = Math.max(lastCheckedIndex, index) - - const newSelectedRowIds = { - ...selectedRowIds, - } + const handleHover = (checkbox: HTMLInputElement, event: MouseEvent) => { + const isShiftPressed = event.shiftKey + + const isHoverActive = + isShiftPressed && lastCheckedIndex !== null && !checkbox.disabled + + if (isHoverActive) { + setInRange(prev => new Set([...prev, checkbox.value])) + } - for (let i = start; i <= end; i += 1) { - const checkbox = refList.current[i] - const checkboxValue = checkbox.value + if (!lastCheckedIndex && !checkbox.disabled) { + setLastCheckedIndex(checkbox.value) + } + } + + const handleClickRange = (checkbox: HTMLInputElement) => { + const shouldShiftEvent = inRange.size > 0 + const isClickInsideRange = inRange.has(checkbox.value) + + if (shouldShiftEvent && isClickInsideRange) { + let checkboxRows: RowState = {} - if (!checkbox.disabled) { - if (checked) { - newSelectedRowIds[checkboxValue] = false - } else { - newSelectedRowIds[checkboxValue] = true - } + refList.current.forEach((value, key) => { + if (inRange.has(key)) { + checkboxRows = { + ...checkboxRows, + // handle the conflict event ( click and onChange in the same time on the last checkbox click) + [key]: key === checkbox.value ? !value.checked : value.checked, } } - setSelectedRowIds(newSelectedRowIds) - if (onSelectedChange) { - onSelectedChange( - Object.keys(newSelectedRowIds).filter( - row => newSelectedRowIds[row], - ), - ) - } + }) + const state = checkStateOfCheckboxs(checkboxRows) + const checkboxIds = Object.keys(checkboxRows) + + if (state === true) { + selectRows(checkboxIds, false) } - } else setLastCheckedIndex(null) + if ([false, 'indeterminate'].includes(state)) { + selectRows(checkboxIds, true) + } + } + + /** + * Handle the case when there is multiple selected value during a time, and the user click without shift event + */ + setTimeout(() => { + // clean up + setInRange(new Set([])) + setLastCheckedIndex(checkbox.value) + }, 1) } - const handleHover = ( - index: number, - isShiftPressed: boolean, - leaving: boolean, - ) => { - const newRange: string[] = [] - - if (isShiftPressed && lastCheckedIndex !== null) { - const start = Math.min(lastCheckedIndex, index) - const end = Math.max(lastCheckedIndex, index) - - for (let i = start; i < end; i += 1) { - const checkbox = refList.current[i] - if (!checkbox.disabled && !leaving) { - newRange.push(checkbox.value) - } - } + const handleOnChange = (checkbox: HTMLInputElement) => { + const shouldHandleEvent = inRange.size === 0 + + if (shouldHandleEvent) { + selectRows([checkbox.value], !checkbox.checked) } - setInRange(newRange) + setLastCheckedIndex(checkbox.value) } - refList.current.forEach((checkbox, index) => { - const clickHandler = (event: MouseEvent) => - handleClick( - index, - event.shiftKey, - selectedRowIds[(event.target as HTMLInputElement).value], - ) + refList.current.forEach(checkbox => { + function clickHandler(this: HTMLInputElement) { + handleClickRange(this) + } - const hoverEnteringHandler = (event: MouseEvent) => - handleHover(index, event.shiftKey, false) + function hoverHandler(this: HTMLInputElement, event: MouseEvent) { + handleHover(this, event) + } - const hoverLeavingHandler = (event: MouseEvent) => - handleHover(index, event.shiftKey, true) + function changeHandler(this: HTMLInputElement) { + handleOnChange(this) + } + checkbox.addEventListener('change', changeHandler) checkbox.addEventListener('click', clickHandler) - checkbox.addEventListener('mousemove', hoverEnteringHandler) - checkbox.addEventListener('mouseout', hoverLeavingHandler) + checkbox.addEventListener('mouseover', hoverHandler) handlers.push(() => { + checkbox.removeEventListener('change', changeHandler) checkbox.removeEventListener('click', clickHandler) - checkbox.removeEventListener('mouseout', hoverEnteringHandler) - checkbox.removeEventListener('mousemove', hoverLeavingHandler) + checkbox.removeEventListener('mouseover', hoverHandler) }) }) } @@ -286,44 +285,50 @@ export const ListProvider = ({ return () => { handlers.forEach(cleanup => cleanup()) } - }, [lastCheckedIndex, onSelectedChange, selectedRowIds, unselectRow]) + }, [inRange, lastCheckedIndex, selectRows]) + + useEffect(subscribeHandler, [subscribeHandler]) const value = useMemo( () => ({ - registerExpandableRow, + allRowSelectValue, + selectAllHandler, + subscribeHandler, + collapseRow, + columns, + expandButton, expandedRowIds, expandRow, - collapseRow, + inRange, + mapCheckbox: refList.current, + registerExpandableRow, registerSelectableRow, - selectedRowIds, - selectRow, - unselectRow, selectable, selectAll, + selectedRowIds, + selectRow, unselectAll, - allRowSelectValue, - expandButton, - refList, - inRange, - columns, + unselectRow, }), [ - registerExpandableRow, + allRowSelectValue, + selectAllHandler, + subscribeHandler, + collapseRow, + columns, + expandButton, expandedRowIds, expandRow, - collapseRow, + inRange, + refList, + registerExpandableRow, registerSelectableRow, - selectedRowIds, - selectRow, - unselectRow, selectable, selectAll, + selectedRowIds, + selectRow, unselectAll, - allRowSelectValue, - expandButton, - refList, - inRange, - columns, + unselectRow, ], ) diff --git a/packages/ui/src/components/List/Row.tsx b/packages/ui/src/components/List/Row.tsx index 539753d3f6..c82edd3138 100644 --- a/packages/ui/src/components/List/Row.tsx +++ b/packages/ui/src/components/List/Row.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@emotion/react' import styled from '@emotion/styled' -import type { ForwardedRef, ReactNode } from 'react' +import type { ReactNode } from 'react' import { Children, forwardRef, @@ -48,10 +48,24 @@ const ExpandableWrapper = styled.tr` const StyledCheckbox = styled(Checkbox, { shouldForwardProp: prop => !['inRange'].includes(prop), })<{ inRange: boolean }>` + ${({ theme, inRange }) => + inRange + ? `svg { + padding: ${theme.space[0.25]}; + outline: 1px inset ${theme.colors.primary.backgroundStrong}; + box-shadow: ${theme.shadows.focusPrimary}; + transition: + box-shadow 250ms ease, + outline 250ms ease, + padding 250ms ease; + rect { + fill: ${theme.colors.primary.backgroundHover}; + stroke: ${theme.colors.primary.borderHover}; + } + } + ` + : ''} - rect { - ${({ theme, inRange }) => (inRange ? `fill: ${theme.colors.neutral.backgroundHover};stroke: ${theme.colors.neutral.borderHover};` : '')} - } ` export const StyledRow = styled('tr', { @@ -192,7 +206,7 @@ type RowProps = { 'data-testid'?: string } -export const Row = forwardRef( +export const Row = forwardRef( ( { children, @@ -205,8 +219,8 @@ export const Row = forwardRef( className, expandablePadding, 'data-testid': dataTestid, - }: RowProps, - ref: ForwardedRef, + }, + forwardedRef, ) => { const { selectable, @@ -216,10 +230,8 @@ export const Row = forwardRef( collapseRow, registerSelectableRow, selectedRowIds, - selectRow, - unselectRow, expandButton, - refList, + mapCheckbox, inRange, columns, } = useListContext() @@ -265,13 +277,16 @@ export const Row = forwardRef( const canClickRowToExpand = !disabled && !!expandable && !expandButton useEffect(() => { - const refAtEffectStart = refList.current const { current } = checkboxRef - if (refAtEffectStart && current && !refAtEffectStart.includes(current)) { - refList.current.push(current) + if (current) { + mapCheckbox.set(id, current) + } + + return () => { + mapCheckbox.delete(id) } - }, [refList]) + }, [mapCheckbox, id]) const childrenLength = Children.count(children) + (selectable ? 1 : 0) + (expandButton ? 1 : 0) @@ -280,7 +295,7 @@ export const Row = forwardRef( <> { - if (selectedRowIds[id]) { - unselectRow(id) - } else { - selectRow(id) - } - }} disabled={isSelectDisabled} - inRange={inRange.includes(id)} + inRange={inRange?.has(id)} /> diff --git a/packages/ui/src/components/List/__stories__/Selectable.stories.tsx b/packages/ui/src/components/List/__stories__/Selectable.stories.tsx index 8e0137a8f4..9d7f7fb51f 100644 --- a/packages/ui/src/components/List/__stories__/Selectable.stories.tsx +++ b/packages/ui/src/components/List/__stories__/Selectable.stories.tsx @@ -22,6 +22,7 @@ Selectable.args = { ? "Earth isn't selectable" : undefined } + expandable={false} > {planet.name} diff --git a/packages/ui/src/components/List/__tests__/index.test.tsx b/packages/ui/src/components/List/__tests__/index.test.tsx index 1f25d239c0..c7db49216f 100644 --- a/packages/ui/src/components/List/__tests__/index.test.tsx +++ b/packages/ui/src/components/List/__tests__/index.test.tsx @@ -261,6 +261,7 @@ describe('List', () => { expect(firstRowCheckbox).not.toBeChecked() await userEvent.click(allCheckbox) expect(firstRowCheckbox).toBeChecked() + expect(asFragment()).toMatchSnapshot() }) diff --git a/packages/ui/src/components/List/index.tsx b/packages/ui/src/components/List/index.tsx index 3f991e548a..78893d338c 100644 --- a/packages/ui/src/components/List/index.tsx +++ b/packages/ui/src/components/List/index.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled' -import type { Dispatch, ForwardedRef, ReactNode, SetStateAction } from 'react' +import type { Dispatch, ReactNode, SetStateAction } from 'react' import { forwardRef } from 'react' import { Cell } from './Cell' import { HeaderCell } from './HeaderCell' @@ -25,6 +25,7 @@ const StyledTable = styled.table` position: relative; ` +// TODO: Get type optional type from omit values of ListContext type ListProps = { expandable?: boolean selectable?: boolean @@ -44,7 +45,7 @@ type ListProps = { onSelectedChange?: Dispatch> } -const BaseList = forwardRef( +const BaseList = forwardRef( ( { expandable = false, @@ -54,8 +55,8 @@ const BaseList = forwardRef( loading, autoCollapse = false, onSelectedChange, - }: ListProps, - ref: ForwardedRef, + }, + ref, ) => ( = args => Template.args = { - checked: false, label: 'Label 1', disabled: false, name: 'basic', diff --git a/packages/ui/src/components/Radio/index.tsx b/packages/ui/src/components/Radio/index.tsx index 28560e885f..7a3035a2cc 100644 --- a/packages/ui/src/components/Radio/index.tsx +++ b/packages/ui/src/components/Radio/index.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled' -import type { ForwardedRef, InputHTMLAttributes, ReactNode } from 'react' +import type { InputHTMLAttributes, ReactNode } from 'react' import { forwardRef, useId } from 'react' import type { LabelProp } from '../../types' import { Stack } from '../Stack' @@ -150,7 +150,6 @@ const MargedText = styled(Text)` type RadioProps = { error?: ReactNode - checked?: boolean value: string | number helper?: ReactNode className?: string @@ -168,13 +167,14 @@ type RadioProps = { | 'name' | 'required' | 'tabIndex' + | 'checked' > & LabelProp /** * Radio component is used to select a single option from a list of options. It is a type of input component. */ -export const Radio = forwardRef( +export const Radio = forwardRef( ( { checked = false, @@ -194,8 +194,8 @@ export const Radio = forwardRef( 'aria-label': ariaLabel, 'data-testid': dataTestId, tabIndex, - }: RadioProps, - ref: ForwardedRef, + }, + forwadedRef, ) => { const id = useId() const computedName = name ?? id @@ -225,7 +225,7 @@ export const Radio = forwardRef( disabled={disabled} name={computedName} autoFocus={autoFocus} - ref={ref} + ref={forwadedRef} tabIndex={tabIndex} /> diff --git a/packages/ui/src/components/SearchInput/index.tsx b/packages/ui/src/components/SearchInput/index.tsx index 2de67c4fd6..1baf456ed7 100644 --- a/packages/ui/src/components/SearchInput/index.tsx +++ b/packages/ui/src/components/SearchInput/index.tsx @@ -52,7 +52,7 @@ const StyledTextInputV2 = styled(TextInputV2)` * - `isOpen`: a boolean indicating if the popup is open * - `toggleIsOpen`: a function to toggle the popup */ -export const SearchInput = forwardRef( +export const SearchInput = forwardRef( ( { placeholder, diff --git a/packages/ui/src/components/SwitchButton/index.tsx b/packages/ui/src/components/SwitchButton/index.tsx index 8ce09a7f99..cd783a865e 100644 --- a/packages/ui/src/components/SwitchButton/index.tsx +++ b/packages/ui/src/components/SwitchButton/index.tsx @@ -111,7 +111,7 @@ export const SwitchButton = ({ [leftButton.value, rightButton.value, value], ) - const [localValue, setLocalValue] = useState(getValueToUse()) + const [localValue, setLocalValue] = useState(getValueToUse) useEffect(() => { setLocalValue(getValueToUse()) diff --git a/packages/ui/src/components/Table/HeaderRow.tsx b/packages/ui/src/components/Table/HeaderRow.tsx index 0dbf59fd99..58ba6f45ab 100644 --- a/packages/ui/src/components/Table/HeaderRow.tsx +++ b/packages/ui/src/components/Table/HeaderRow.tsx @@ -11,13 +11,8 @@ type HeaderRowProps = { } export const HeaderRow = ({ children, hasSelectAllColumn }: HeaderRowProps) => { - const { - allRowSelectValue, - selectAll, - unselectAll, - selectedRowIds, - expandButton, - } = useTableContext() + const { allRowSelectValue, selectAllHandler, selectedRowIds, expandButton } = + useTableContext() const theme = useTheme() const selectableRowCount = Object.keys(selectedRowIds).length @@ -31,7 +26,7 @@ export const HeaderRow = ({ children, hasSelectAllColumn }: HeaderRowProps) => { value="all" aria-label="select all" checked={allRowSelectValue} - onChange={allRowSelectValue === false ? selectAll : unselectAll} + onChange={selectAllHandler} disabled={selectableRowCount === 0} /> diff --git a/packages/ui/src/components/Table/Row.tsx b/packages/ui/src/components/Table/Row.tsx index b8bbee4356..6b6f01bdbc 100644 --- a/packages/ui/src/components/Table/Row.tsx +++ b/packages/ui/src/components/Table/Row.tsx @@ -31,9 +31,25 @@ const StyledCheckbox = styled(Checkbox, { shouldForwardProp: prop => !['inRange'].includes(prop), })<{ inRange: boolean }>` - rect { - ${({ theme, inRange }) => (inRange ? `fill: ${theme.colors.neutral.backgroundHover};stroke: ${theme.colors.neutral.borderHover};` : '')} - } + + ${({ theme, inRange }) => + inRange + ? `svg { + padding: ${theme.space[0.25]}; + outline: 1px inset ${theme.colors.primary.backgroundStrong}; + box-shadow: ${theme.shadows.focusPrimary}; + transition: + box-shadow 250ms ease, + outline 250ms ease, + padding 250ms ease; + rect { + fill: ${theme.colors.primary.backgroundHover}; + stroke: ${theme.colors.primary.borderHover}; + } + } + ` + : ''} + ` // We start at 5% and finish at 80% to leave the original background color @@ -115,14 +131,13 @@ export const Row = ({ collapseRow, registerSelectableRow, selectedRowIds, - selectRow, - unselectRow, expandButton, - ref, + mapCheckbox, inRange, columns, } = useTableContext() - const rowRef = useRef(null) + + const checkboxRowRef = useRef(null) const hasExpandable = !!expandable useEffect(() => { @@ -156,13 +171,16 @@ export const Row = ({ const canClickRowToExpand = hasExpandable && !expandButton useEffect(() => { - const refAtEffectStart = ref.current - const { current } = rowRef + const { current } = checkboxRowRef + + if (current) { + mapCheckbox.set(id, current) + } - if (refAtEffectStart && current && !refAtEffectStart.includes(current)) { - ref.current.push(current) + return () => { + mapCheckbox.delete(id) } - }, [ref]) + }, [mapCheckbox, id]) const theme = useTheme() @@ -194,16 +212,9 @@ export const Row = ({ aria-label="select" checked={selectedRowIds[id]} value={id} - onChange={() => { - if (selectedRowIds[id]) { - unselectRow(id) - } else { - selectRow(id) - } - }} + inRange={inRange?.has(id)} disabled={selectDisabled !== undefined} - ref={rowRef} - inRange={inRange.includes(id)} + ref={checkboxRowRef} /> diff --git a/packages/ui/src/components/Table/TableContext.tsx b/packages/ui/src/components/Table/TableContext.tsx index 359a9e03ff..6f034669f5 100644 --- a/packages/ui/src/components/Table/TableContext.tsx +++ b/packages/ui/src/components/Table/TableContext.tsx @@ -1,295 +1,64 @@ -import type { ComponentProps, ReactNode, RefObject } from 'react' -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import type { Checkbox } from '../Checkbox' +import { createContext, useContext, useEffect, useMemo } from 'react' +import type { ListContextValue, ListProviderProps } from '../List/ListContext' +import { ListProvider, useListContext } from '../List/ListContext' import type { ColumnProps } from './types' -type RowState = Record - -type TableContextValue = { - bordered: boolean +type TableContextValue = Omit & { stripped: boolean - ref: RefObject - // ============ Selectable logic ============ - selectedRowIds: RowState - selectRow: (rowId: string) => void - unselectRow: (rowId: string) => void - selectable: boolean - allRowSelectValue: ComponentProps['checked'] - selectAll: () => void - unselectAll: () => void - /** - * @returns an unregister function - * */ - registerSelectableRow: (rowId: string) => () => void - inRange: string[] - // ============ Expandable logic ============ - expandedRowIds: RowState - expandRow: (rowId: string) => void - collapseRow: (rowId: string) => void - expandButton: boolean - registerExpandableRow: (rowId: string, expanded?: boolean) => () => void columns: ColumnProps[] } const TableContext = createContext(undefined) -type TableProviderProps = { - children: ReactNode - selectable: boolean - bordered: boolean +export type TableProviderProps = Omit & { stripped: boolean - expandButton: boolean - autoCollapse: boolean + bordered: boolean columns: ColumnProps[] } -export const TableProvider = ({ +const Provider = ({ children, - selectable, bordered, stripped, - expandButton, - autoCollapse, columns, }: TableProviderProps) => { - const [selectedRowIds, setSelectedRowIds] = useState({}) - const [expandedRowIds, setExpandedRowIds] = useState({}) - const ref = useRef([]) - - const registerExpandableRow = useCallback( - (rowId: string, expanded = false) => { - setExpandedRowIds(current => ({ ...current, [rowId]: expanded })) - - return () => { - setExpandedRowIds(current => { - const { [rowId]: relatedId, ...otherIds } = current - - return otherIds - }) - } - }, - [], - ) - - const expandRow = useCallback( - (rowId: string) => { - setExpandedRowIds(current => ({ - ...(autoCollapse ? {} : current), - [rowId]: true, - })) - }, - [autoCollapse], - ) - - const collapseRow = useCallback((rowId: string) => { - setExpandedRowIds(current => ({ - ...current, - [rowId]: false, - })) - }, []) - - const registerSelectableRow = useCallback((rowId: string) => { - setSelectedRowIds(current => ({ ...current, [rowId]: false })) - - return () => { - setSelectedRowIds(current => { - const { [rowId]: relatedId, ...otherIds } = current - - return otherIds - }) - } - }, []) - - const allRowSelectValue = useMemo< - ComponentProps['checked'] - >(() => { - const selectableRowCount = Object.keys(selectedRowIds).length - if (!selectableRowCount) { - return false - } - - const selectedRowCount = Object.values(selectedRowIds).reduce( - (acc, isSelected) => acc + (isSelected ? 1 : 0), - 0, - ) - if (selectedRowCount === 0) { - return false - } - if (selectableRowCount === selectedRowCount) { - return true - } - - return 'indeterminate' - }, [selectedRowIds]) - - const selectAll = useCallback(() => { - setSelectedRowIds(current => - Object.keys(current).reduce( - (acc, rowId) => ({ ...acc, [rowId]: true }), - {}, - ), - ) - }, []) - - const unselectAll = useCallback(() => { - setSelectedRowIds(current => - Object.keys(current).reduce( - (acc, rowId) => ({ ...acc, [rowId]: false }), - {}, - ), - ) - }, []) - - const selectRow = useCallback((rowId: string) => { - setSelectedRowIds(current => ({ - ...current, - [rowId]: true, - })) - }, []) - - const unselectRow = useCallback((rowId: string) => { - setSelectedRowIds(current => ({ - ...current, - [rowId]: false, - })) - }, []) - - const [lastCheckedIndex, setLastCheckedIndex] = useState(null) - const [inRange, setInRange] = useState([]) + const { subscribeHandler, ...listContext } = useListContext() - // Multiselect with shift key - useEffect(() => { - const handlers: (() => void)[] = [] - - if (ref.current) { - const handleClick = ( - index: number, - isShiftPressed: boolean, - checked: boolean, - ) => { - setLastCheckedIndex(index) - if (isShiftPressed && lastCheckedIndex !== null) { - const start = Math.min(lastCheckedIndex, index) - const end = Math.max(lastCheckedIndex, index) - - for (let i = start; i <= end; i += 1) { - const checkbox = ref.current[i] - const checkboxValue = checkbox.value - if (!checkbox.disabled) { - if (checked) unselectRow(checkboxValue) - else selectRow(checkboxValue) - } - } - } - } - - const handleHover = ( - index: number, - isShiftPressed: boolean, - leaving: boolean, - ) => { - const newRange: string[] = [] - - if (isShiftPressed && lastCheckedIndex !== null) { - const start = Math.min(lastCheckedIndex, index) - const end = Math.max(lastCheckedIndex, index) - - for (let i = start; i < end; i += 1) { - const checkbox = ref.current[i] - if (!checkbox.disabled && !leaving) { - newRange.push(checkbox.value) - } - } - } - setInRange(newRange) - } - - ref.current.forEach((checkbox, index) => { - const clickHandler = (event: MouseEvent) => - handleClick( - index, - event.shiftKey, - selectedRowIds[(event.target as HTMLInputElement).value], - ) - - const hoverEnteringHandler = (event: MouseEvent) => - handleHover(index, event.shiftKey, false) - - const hoverLeavingHandler = (event: MouseEvent) => - handleHover(index, event.shiftKey, true) - - checkbox.addEventListener('click', clickHandler) - checkbox.addEventListener('mousemove', hoverEnteringHandler) - checkbox.addEventListener('mouseleave', hoverLeavingHandler) - - handlers.push(() => { - checkbox.removeEventListener('click', clickHandler) - checkbox.removeEventListener('mousemove', hoverLeavingHandler) - checkbox.removeEventListener('mouseenter', hoverEnteringHandler) - }) - }) - } - - return () => { - handlers.forEach(cleanup => cleanup()) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastCheckedIndex, selectedRowIds]) + useEffect(subscribeHandler, [subscribeHandler]) const value = useMemo( () => ({ - registerSelectableRow, - selectedRowIds, - selectRow, - unselectRow, - selectable, - selectAll, - unselectAll, - allRowSelectValue, + ...listContext, + subscribeHandler, bordered, - stripped, - expandButton, - expandRow, - expandedRowIds, - collapseRow, - registerExpandableRow, - ref, - inRange, columns, - }), - [ - registerSelectableRow, - selectedRowIds, - selectRow, - unselectRow, - selectable, - selectAll, - unselectAll, - allRowSelectValue, - bordered, stripped, - expandedRowIds, - expandRow, - expandButton, - collapseRow, - registerExpandableRow, - ref, - inRange, - columns, - ], + }), + [bordered, columns, stripped, subscribeHandler, listContext], ) - return {children} + return {children} } +export const TableProvider = ({ + children, + bordered, + stripped, + columns, + ...props +}: TableProviderProps) => ( + + + {children} + + +) + export const useTableContext = () => { const context = useContext(TableContext) if (!context) { diff --git a/packages/ui/src/components/Table/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/Table/__tests__/__snapshots__/index.test.tsx.snap index 32a1683059..a85fa381e0 100644 --- a/packages/ui/src/components/Table/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/Table/__tests__/__snapshots__/index.test.tsx.snap @@ -4133,6 +4133,19 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` fill: #ffebf2; } +.emotion-58 svg { + padding: 0.125rem; + outline: 1px inset #8c40ef; + box-shadow: 0px 0px 0px 3px #8c40ef40; + -webkit-transition: box-shadow 250ms ease,outline 250ms ease,padding 250ms ease; + transition: box-shadow 250ms ease,outline 250ms ease,padding 250ms ease; +} + +.emotion-58 svg rect { + fill: #e5dbfd; + stroke: #792dd4; +} + .emotion-70 { display: table-cell; vertical-align: middle; @@ -4149,6 +4162,144 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` text-align: left; } +.emotion-148 { + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: start; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: start; + gap: 0.5rem; +} + +.emotion-148 .eqr7bqq4 { + cursor: pointer; +} + +.emotion-148[aria-disabled='true'] { + cursor: not-allowed; + color: #b5b7bd; +} + +.emotion-148[aria-disabled='true'] .eqr7bqq4 { + cursor: not-allowed; +} + +.emotion-148[aria-disabled='true'] .emotion-16 { + fill: #e9eaeb; +} + +.emotion-148[aria-disabled='true'] .emotion-16 .emotion-18 { + stroke: #d9dadd; + fill: #f3f3f4; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-invalid="true"]:checked+.emotion-16 { + fill: #ffd3e3; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-invalid="true"]:checked+.emotion-16 .emotion-18 { + stroke: #ffd3e3; + fill: #ffd3e3; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-invalid="true"]+.emotion-16 { + fill: #ffebf2; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-invalid="true"]+.emotion-16 .emotion-18 { + stroke: #ffbbd3; + fill: #ffebf2; +} + +.emotion-148[aria-disabled='true'] .emotion-14:checked+.emotion-16 { + fill: #e5dbfd; +} + +.emotion-148[aria-disabled='true'] .emotion-14:checked+.emotion-16 .emotion-18 { + stroke: #d8c5fc; + fill: #d8c5fc; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-checked="mixed"]+.emotion-16 { + fill: #e5dbfd; +} + +.emotion-148[aria-disabled='true'] .emotion-14[aria-checked="mixed"]+.emotion-16 .emotion-18 { + stroke: #e5dbfd; + fill: #e5dbfd; +} + +.emotion-148 .emotion-14:checked+.emotion-16 path { + transform-origin: center; + -webkit-transition: 200ms -webkit-transform ease-in-out; + transition: 200ms transform ease-in-out; + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + -webkit-transform: translate(2px, 2px); + -moz-transform: translate(2px, 2px); + -ms-transform: translate(2px, 2px); + transform: translate(2px, 2px); +} + +.emotion-148 .emotion-14:checked+.emotion-16 .emotion-18 { + fill: #8c40ef; + stroke: #8c40ef; +} + +.emotion-148 .emotion-14[aria-invalid="true"]:checked+.emotion-16 .emotion-18 { + fill: #e51963; + stroke: #e51963; +} + +.emotion-148 .emotion-14[aria-checked="mixed"]+.emotion-16 .emotion-20 { + fill: #ffffff; +} + +.emotion-148 .emotion-14[aria-checked="mixed"]+.emotion-16 .emotion-18 { + fill: #8c40ef; + stroke: #8c40ef; +} + +.emotion-148:hover[aria-disabled='false'] .emotion-14[aria-invalid='false'][aria-checked='false']+.emotion-16 .emotion-18 { + stroke: #792dd4; + fill: #e5dbfd; +} + +.emotion-148:hover[aria-disabled='false'] .emotion-14[aria-invalid='false'][aria-checked='true']+.emotion-16 .emotion-18 { + stroke: #792dd4; + fill: #792dd4; +} + +.emotion-148:hover[aria-disabled='false'] .emotion-14[aria-invalid='false'][aria-checked='mixed']+.emotion-16 .emotion-18 { + stroke: #792dd4; + fill: #792dd4; +} + +.emotion-148:hover[aria-disabled='false'] .emotion-14[aria-invalid='true'][aria-checked='false']+.emotion-16 .emotion-18 { + stroke: #92103f; + fill: #ffd3e3; +} + +.emotion-148:hover[aria-disabled='false'] .emotion-14[aria-invalid='true'][aria-checked='true']+.emotion-16 .emotion-18 { + stroke: #d6175c; + fill: #d6175c; +} + +.emotion-148 .emotion-14[aria-invalid="true"]+.emotion-16 { + fill: #e51963; +} + +.emotion-148 .emotion-14[aria-invalid="true"]+.emotion-16 .emotion-18 { + stroke: #e51963; + fill: #ffebf2; +} +
@@ -4554,7 +4705,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -4643,7 +4794,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -4732,7 +4883,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -4821,7 +4972,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -4910,7 +5061,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -4999,7 +5150,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
@@ -5088,7 +5239,7 @@ exports[`Table > Should render correctly with selectable and shift click 1`] = ` >
diff --git a/packages/ui/src/components/Table/__tests__/index.test.tsx b/packages/ui/src/components/Table/__tests__/index.test.tsx index 531f78e36c..8ccdaafabd 100644 --- a/packages/ui/src/components/Table/__tests__/index.test.tsx +++ b/packages/ui/src/components/Table/__tests__/index.test.tsx @@ -128,6 +128,7 @@ describe('Table', () => { const checkboxes = screen.getAllByRole('checkbox') const firstRowCheckbox = checkboxes.find(({ value }) => value === '1') + const secondRowCheckbox = checkboxes.find(({ value }) => value === '2') const allCheckbox = checkboxes.find(({ value }) => value === 'all') expect(firstRowCheckbox).toBeInTheDocument() expect(allCheckbox).toBeInTheDocument() @@ -142,10 +143,14 @@ describe('Table', () => { await userEvent.click(firstRowCheckbox) expect(firstRowCheckbox).not.toBeChecked() await userEvent.click(firstRowCheckbox) + // mixed | indeterminated state await userEvent.click(allCheckbox) expect(firstRowCheckbox).not.toBeChecked() + expect(allCheckbox).not.toBeChecked() await userEvent.click(allCheckbox) expect(firstRowCheckbox).toBeChecked() + expect(secondRowCheckbox).toBeChecked() + expect(allCheckbox).toBeChecked() expect(asFragment()).toMatchSnapshot() }) @@ -415,20 +420,22 @@ describe('Table', () => { } await userEvent.click(firstRowCheckbox) + fireEvent.keyDown(document, { key: 'Shift', code: 'ShiftLeft' }) // Test hovering - fireEvent.mouseMove(secondRowCheckbox, { shiftKey: true }) - fireEvent.mouseMove(thirdRowCheckbox, { shiftKey: true }) - fireEvent.mouseLeave(thirdRowCheckbox, { shiftKey: true }) + fireEvent.mouseOver(firstRowCheckbox, { shiftKey: true }) + fireEvent.mouseOver(secondRowCheckbox, { shiftKey: true }) + fireEvent.mouseOver(thirdRowCheckbox, { shiftKey: true }) + fireEvent.keyUp(document, { key: 'Shift', code: 'ShiftLeft' }) - fireEvent.click(thirdRowCheckbox, { shiftKey: true }) + fireEvent.click(thirdRowCheckbox) + + expect(firstRowCheckbox).toBeChecked() expect(secondRowCheckbox).toBeChecked() expect(thirdRowCheckbox).toBeChecked() - fireEvent.keyUp(document, { key: 'Shift', code: 'ShiftLeft' }) - expect(asFragment()).toMatchSnapshot() }) }) diff --git a/packages/ui/src/components/Table/index.tsx b/packages/ui/src/components/Table/index.tsx index f16f034226..089f379f3e 100644 --- a/packages/ui/src/components/Table/index.tsx +++ b/packages/ui/src/components/Table/index.tsx @@ -1,6 +1,5 @@ import { useTheme } from '@emotion/react' import styled from '@emotion/styled' -import type { ReactNode } from 'react' import { forwardRef } from 'react' import { Body } from './Body' import { Cell } from './Cell' @@ -11,6 +10,7 @@ import { Row } from './Row' import { SelectBar } from './SelectBar' import { SkeletonRows } from './SkeletonRows' import { TableProvider, useTableContext } from './TableContext' +import type { TableProviderProps } from './TableContext' import { EXPANDABLE_COLUMN_SIZE, SELECTABLE_CHECKBOX_SIZE } from './constants' import type { ColumnProps } from './types' @@ -59,11 +59,24 @@ const StyledTable = styled('table', { } `} ` +// type OptionalKeys = { +// [K in keyof T]: {} extends Pick ? K : never +// }[keyof T] -type TableProps = { +// type OptionalOnly = Pick> + +// TODO: Get type optional from omit values +type TableProps = Omit< + TableProviderProps, + | 'selectable' + | 'loading' + | 'bordered' + | 'stripped' + | 'autoCollapse' + | 'columns' + | 'expandButton' +> & { selectable?: boolean - columns: ColumnProps[] - children: ReactNode /** * Set it to true if you want to display a placeholder during loading * */ @@ -75,6 +88,8 @@ type TableProps = { * Auto collapse is collapsing expandable row when another is expanding * */ autoCollapse?: boolean + expandButton?: boolean + columns: ColumnProps[] } export const BaseTable = forwardRef( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 903cc784f2..447309588d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@manypkg/cli': specifier: 0.23.0 version: 0.23.0 + '@playwright/test': + specifier: 1.49.1 + version: 1.49.1 '@scaleway/eslint-config-react': specifier: 4.0.9 version: 4.0.9(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.2) @@ -331,7 +334,7 @@ importers: version: link:../../packages/ui next: specifier: 15.1.2 - version: 15.1.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.1.2(@babel/core@7.26.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -389,7 +392,7 @@ importers: version: link:../../packages/ui next: specifier: 15.1.2 - version: 15.1.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.1.2(@babel/core@7.26.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -438,7 +441,7 @@ importers: version: link:../../packages/ui next: specifier: 15.1.2 - version: 15.1.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.1.2(@babel/core@7.26.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -2551,6 +2554,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -4922,6 +4930,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6327,6 +6340,16 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -10190,6 +10213,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.49.1': + dependencies: + playwright: 1.49.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -13104,6 +13131,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -14378,7 +14408,7 @@ snapshots: dependencies: enhanced-resolve: 5.16.0 - next@15.1.2(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.1.2(@babel/core@7.26.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.1.2 '@swc/counter': 0.1.3 @@ -14398,6 +14428,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.1.2 '@next/swc-win32-arm64-msvc': 15.1.2 '@next/swc-win32-x64-msvc': 15.1.2 + '@playwright/test': 1.49.1 sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' @@ -14695,6 +14726,14 @@ snapshots: dependencies: find-up: 6.3.0 + playwright-core@1.49.1: {} + + playwright@1.49.1: + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + polished@4.3.1: dependencies: '@babel/runtime': 7.26.0