diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index bf8c967..ad2a99c 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -23,7 +23,7 @@ concurrency: jobs: benchmarks: - runs-on: ubuntu-latest + runs-on: codspeed-macro timeout-minutes: 30 permissions: contents: read diff --git a/packages/base-ui/src/components/checkbox/Checkbox.module.css b/packages/base-ui/src/components/checkbox/Checkbox.module.css index ea71bc0..56d3e08 100644 --- a/packages/base-ui/src/components/checkbox/Checkbox.module.css +++ b/packages/base-ui/src/components/checkbox/Checkbox.module.css @@ -31,7 +31,8 @@ grid-template-columns: minmax(0, 1fr) var(--_ov-control-size); } -.Root[data-ov-label-position='start'] .Control { +.Root[data-ov-label-position='start'] .Control, +.Root[data-ov-label-position='start'] .ControlIndicator { grid-column: 2; } @@ -168,24 +169,28 @@ box-shadow var(--ov-duration-interactive) var(--ov-ease-standard); } -.Root:not([data-disabled], [disabled]):hover .Control { +.Root:not([data-disabled], [disabled]):hover .Control, +.Root:not([data-disabled], [disabled]):hover .ControlIndicator { background: var(--_ov-control-bg-hover); border-color: var(--_ov-control-border-hover); } -.Root:is([data-checked], [data-indeterminate]) .Control { +.Root:is([data-checked], [data-indeterminate]) .Control, +.Root:is([data-checked], [data-indeterminate]) .ControlIndicator { background: var(--_ov-control-bg-checked); border-color: var(--_ov-control-border-checked); color: var(--_ov-control-fg-checked); } -.Root:focus-visible .Control { +.Root:focus-visible .Control, +.Root:focus-visible .ControlIndicator { border-color: var(--_ov-focus-border); box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); } .Indicator { display: inline-flex; + position: relative; align-items: center; justify-content: center; inline-size: 100%; @@ -202,22 +207,96 @@ transform: scale(1); } -.DefaultCheckIcon, -.DefaultMinusIcon { - inline-size: var(--_ov-indicator-size); - block-size: var(--_ov-indicator-size); +/* ── Fallback pseudo-elements for compound-part consumers using .Indicator with no children ── */ + +.Indicator:empty::before { + content: ''; + display: none; + position: absolute; + top: 50%; + left: 50%; + width: 35%; + height: 60%; + border-bottom: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: translate(-50%, -60%) rotate(45deg); +} + +.Root[data-checked]:not([data-indeterminate]) .Indicator:empty::before { + display: block; } -.DefaultMinusIcon { +.Indicator:empty::after { + content: ''; display: none; + position: absolute; + top: 50%; + left: 50%; + width: 55%; + height: 2px; + background: currentColor; + border-radius: 1px; + transform: translate(-50%, -50%); } -.Root[data-indeterminate] .DefaultCheckIcon { +.Root[data-indeterminate] .Indicator:empty::after { + display: block; +} + +/* ── Combined Control + Indicator (flattened path: no custom indicator, keepMounted=true) ── */ + +.ControlIndicator { + display: inline-flex; + position: relative; + align-items: center; + justify-content: center; + inline-size: var(--_ov-control-size); + block-size: var(--_ov-control-size); + border-radius: var(--ov-size-choice-radius); + border: 1px solid var(--_ov-control-border); + background: var(--_ov-control-bg); + color: var(--_ov-control-fg); + transition: + background-color var(--ov-duration-interactive) var(--ov-ease-standard), + border-color var(--ov-duration-interactive) var(--ov-ease-standard), + color var(--ov-duration-interactive) var(--ov-ease-standard), + box-shadow var(--ov-duration-interactive) var(--ov-ease-standard); +} + +/* ── Pseudo-element indicators on .ControlIndicator ── */ + +.ControlIndicator::before { + content: ''; display: none; + position: absolute; + top: 50%; + left: 50%; + width: 35%; + height: 60%; + border-bottom: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: translate(-50%, -60%) rotate(45deg); } -.Root[data-indeterminate] .DefaultMinusIcon { - display: inline; +.Root[data-checked]:not([data-indeterminate]) .ControlIndicator::before { + display: block; +} + +.ControlIndicator::after { + content: ''; + display: none; + position: absolute; + top: 50%; + left: 50%; + width: 55%; + height: 2px; + background: currentColor; + border-radius: 1px; + transform: translate(-50%, -50%); +} + +.Root[data-indeterminate] .ControlIndicator::after { + display: block; } .Content { diff --git a/packages/base-ui/src/components/checkbox/Checkbox.test.tsx b/packages/base-ui/src/components/checkbox/Checkbox.test.tsx index 4155d12..7066f2d 100644 --- a/packages/base-ui/src/components/checkbox/Checkbox.test.tsx +++ b/packages/base-ui/src/components/checkbox/Checkbox.test.tsx @@ -35,6 +35,32 @@ describe('Checkbox', () => { expect(screen.getByTestId('indicator')).toBeInTheDocument(); }); + it('renders default checkbox without icon child elements', () => { + const { container } = renderWithTheme(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-checked'); + + // Flattened path: no LuCheck/LuMinus SVGs + const svgs = container.querySelectorAll('svg'); + expect(svgs).toHaveLength(0); + }); + + it('falls back to nested structure when keepIndicatorMounted is false', () => { + renderWithTheme( + + Nested path + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Nested path' }); + expect(checkbox).toHaveAttribute('data-checked'); + }); + + it('renders indeterminate state in flattened path', () => { + renderWithTheme(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('data-indeterminate'); + }); + it('applies label position and spread layout attributes', () => { renderWithTheme( diff --git a/packages/base-ui/src/components/checkbox/Checkbox.tsx b/packages/base-ui/src/components/checkbox/Checkbox.tsx index ed298a1..201a9ad 100644 --- a/packages/base-ui/src/components/checkbox/Checkbox.tsx +++ b/packages/base-ui/src/components/checkbox/Checkbox.tsx @@ -6,7 +6,6 @@ import { type HTMLAttributes, type ReactNode, } from 'react'; -import { LuCheck, LuMinus } from 'react-icons/lu'; import { cn, withBaseClassName } from '../../system/classnames'; import { styleDataAttributes } from '../../system/styleProps'; import type { StyledComponentProps } from '../../system/types'; @@ -98,6 +97,7 @@ const CheckboxItem = forwardRef, CheckboxIt ref, ) { const hasContent = Boolean(children) || Boolean(description); + const useFlattened = indicator === undefined && keepIndicatorMounted; return ( , CheckboxIt data-ov-layout={layout} {...props} > - - - {indicator ?? ( - <> - - - - )} - - + {useFlattened ? ( + + ) : ( + + + {indicator} + + + )} {hasContent ? ( {children ? {children} : null} diff --git a/packages/benchmarks/src/base-ui/DataTable.bench.tsx b/packages/benchmarks/src/base-ui/DataTable.bench.tsx index bd96c17..a3a3a87 100644 --- a/packages/benchmarks/src/base-ui/DataTable.bench.tsx +++ b/packages/benchmarks/src/base-ui/DataTable.bench.tsx @@ -50,8 +50,10 @@ function DataTableBench({ data }: { data: Row[] }) { return ( - - + + + + ); } diff --git a/packages/benchmarks/src/stubs/react-syntax-highlighter.ts b/packages/benchmarks/src/stubs/react-syntax-highlighter.ts new file mode 100644 index 0000000..ab7ff57 --- /dev/null +++ b/packages/benchmarks/src/stubs/react-syntax-highlighter.ts @@ -0,0 +1,5 @@ +// Stub: react-syntax-highlighter is imported transitively via the base-ui +// barrel (CodeBlock component) but no benchmark uses it. The real package +// fails in CodSpeed's forked CJS process because refractor is ESM-only. +export const Prism = () => null; +export default () => null; diff --git a/packages/benchmarks/vitest.config.ts b/packages/benchmarks/vitest.config.ts index af34125..c20af22 100644 --- a/packages/benchmarks/vitest.config.ts +++ b/packages/benchmarks/vitest.config.ts @@ -43,10 +43,18 @@ export default defineConfig(async () => { return { plugins, resolve: { - alias: { - '@omniview/base-ui': path.resolve(__dirname, '../base-ui/src/index.ts'), - '@omniview/editors': path.resolve(__dirname, '../editors/src/index.ts'), - }, + alias: [ + { find: '@omniview/base-ui', replacement: path.resolve(__dirname, '../base-ui/src/index.ts') }, + { find: '@omniview/editors', replacement: path.resolve(__dirname, '../editors/src/index.ts') }, + // Stub out react-syntax-highlighter and all sub-path imports (e.g. + // react-syntax-highlighter/dist/esm/styles/prism). Imported transitively + // via CodeBlock barrel but unused by benchmarks. The real package fails + // in CodSpeed's forked CJS process because refractor is ESM-only. + { + find: /^react-syntax-highlighter(\/.*)?$/, + replacement: path.resolve(__dirname, 'src/stubs/react-syntax-highlighter.ts'), + }, + ], }, test: { benchmark: {