diff --git a/src/MasterMix/MasterMix.test.tsx b/src/MasterMix/MasterMix.test.tsx index c86c71bf..3856b064 100644 --- a/src/MasterMix/MasterMix.test.tsx +++ b/src/MasterMix/MasterMix.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; +import { Provider } from '../Provider'; + import { MasterMixIngredient } from './types'; import { MasterMix } from './index'; @@ -47,6 +49,26 @@ describe('MasterMix', () => { expect(screen.getByText('901.8 µl')).toBeInTheDocument(); }); + it('keeps each volume together with its unit', () => { + const keepWhitespace = (text: string) => text; + + render( + , + ); + + expect( + screen.getByText('79.5\u00A0µl', { normalizer: keepWhitespace }), + ).toBeInTheDocument(); + expect( + screen.getByText('901.8\u00A0µl', { normalizer: keepWhitespace }), + ).toBeInTheDocument(); + }); + it('renders as reaction mix containing the master mix', () => { render( { expect(screen.queryAllByTitle('pipettiert')).toHaveLength(0); }); + + /* eslint-disable testing-library/no-container, testing-library/no-node-access -- antd wrappers have no role */ + it('applies the given style', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.mll-ant-card-body')).toHaveStyle({ + padding: '12px', + }); + expect(container.querySelector('.mll-ant-card-head-title')).toHaveStyle({ + paddingBlock: '4px', + }); + expect(screen.getByText('Water').firstElementChild).toHaveStyle({ + width: '24px', + }); + }); + + it('adds pixels to a numeric header spacing', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.mll-ant-card-head-title')).toHaveStyle({ + paddingBlock: '4px', + }); + }); + + it('keeps the default style without style props', () => { + const { container } = render( + + + , + ); + + expect(container.querySelector('.mll-ant-card-body')).not.toHaveAttribute( + 'style', + ); + expect(container.querySelector('.mll-ant-card-head-title')).not.toHaveStyle( + { paddingBlock: '4px' }, + ); + expect(screen.getByText('Water').firstElementChild).toHaveStyle({ + width: '40px', + }); + }); + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ }); diff --git a/src/MasterMix/MasterMixRowName.tsx b/src/MasterMix/MasterMixRowName.tsx index abb79887..4f6d16e8 100644 --- a/src/MasterMix/MasterMixRowName.tsx +++ b/src/MasterMix/MasterMixRowName.tsx @@ -1,8 +1,10 @@ import React, { ReactNode } from 'react'; import styled from 'styled-components'; +import { MasterMixStyle } from './types'; + const PIPETTED_MARK = '✓'; -const INDENT_STEP_IN_PIXELS = 20; +const DEFAULT_INDENT_STEP_IN_PIXELS = 20; const MARK_GAP_IN_PIXELS = 5; /** @@ -10,11 +12,11 @@ const MARK_GAP_IN_PIXELS = 5; * nor detaches from its row. The offset separates the mark from the name without moving * the name on any level. */ -const Indent = styled.span<{ $level: number }>` +const Indent = styled.span<{ $widthInPixels: number }>` display: inline-block; position: relative; right: ${MARK_GAP_IN_PIXELS}px; - width: ${(props) => props.$level * INDENT_STEP_IN_PIXELS}px; + width: ${(props) => props.$widthInPixels}px; text-align: right; color: ${(props) => props.theme.successColor}; `; @@ -22,15 +24,20 @@ const Indent = styled.span<{ $level: number }>` export function MasterMixRowName({ level, pipetted, + indentStepInPixels = DEFAULT_INDENT_STEP_IN_PIXELS, children, }: { level: number; pipetted: boolean; + indentStepInPixels?: MasterMixStyle['indentStepInPixels']; children: ReactNode; }) { return ( <> - + {pipetted ? PIPETTED_MARK : null} {children} diff --git a/src/MasterMix/MixTable.tsx b/src/MasterMix/MixTable.tsx index fb0e7225..aef4132e 100644 --- a/src/MasterMix/MixTable.tsx +++ b/src/MasterMix/MixTable.tsx @@ -8,12 +8,18 @@ import { } from './VolumeTable'; import { hasPerReactionIngredients } from './hasPerReactionIngredients'; import { mixRows } from './mixRows'; -import { MasterMixTableRow, PipettingScaling, ReactionMix } from './types'; +import { + MasterMixStyle, + MasterMixTableRow, + PipettingScaling, + ReactionMix, +} from './types'; import { volumeColumns } from './volumeColumns'; -type MixTableProps = ReactionMix & { - scaling?: PipettingScaling; -}; +type MixTableProps = ReactionMix & + Pick & { + scaling?: PipettingScaling; + }; function rowClassName( record: MasterMixTableRow, @@ -35,6 +41,7 @@ export function MixTable({ ingredients, perReactionIngredients, scaling, + indentStepInPixels, }: MixTableProps) { const [pipettedKeys, setPipettedKeys] = useState>([]); @@ -60,7 +67,12 @@ export function MixTable({ } : {}) } - columns={volumeColumns(scaling, pipettedKeys, withinReactionMix)} + columns={volumeColumns({ + scaling, + pipettedKeys, + withinReactionMix, + indentStepInPixels, + })} /> ); } diff --git a/src/MasterMix/index.stories.tsx b/src/MasterMix/index.stories.tsx index 00e6645c..894fcf66 100644 --- a/src/MasterMix/index.stories.tsx +++ b/src/MasterMix/index.stories.tsx @@ -99,3 +99,20 @@ export function Recipe({ name, ingredients }: StoryProps): ReactElement { /> ); } + +export function NarrowCard(props: StoryProps): ReactElement { + return ( +
+ +
+ ); +} diff --git a/src/MasterMix/index.tsx b/src/MasterMix/index.tsx index e7391be3..a4e80a6b 100644 --- a/src/MasterMix/index.tsx +++ b/src/MasterMix/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Card } from '../Card'; import { Typography } from '../Typography'; @@ -7,11 +7,12 @@ import { Typography } from '../Typography'; import { MixTable } from './MixTable'; import { hasPerReactionIngredients } from './hasPerReactionIngredients'; import { MASTER_MIX_LABEL } from './mixRows'; -import { MasterMixProps } from './types'; +import { MasterMixProps, MasterMixStyle } from './types'; export { reactionVolume } from './reactionVolume'; export { MasterMixProps, + MasterMixStyle, MasterMixIngredient, ReactionMix, PipettingLoss, @@ -25,7 +26,27 @@ export { * `max-width` on `.mll-ant-table-wrapper`. */ const MixContent = styled.div` - max-width: 400px; + max-width: 480px; +`; + +const MixCard = styled(Card)<{ + $headerSpacing: MasterMixStyle['headerSpacing']; +}>` + ${(props) => + props.$headerSpacing != null && + css` + .mll-ant-card-head { + min-height: 0; + } + .mll-ant-card-head-title { + padding-block: ${typeof props.$headerSpacing === 'number' + ? `${props.$headerSpacing}px` + : props.$headerSpacing}; + } + .mll-ant-card-head-title .mll-ant-typography { + margin-bottom: 0; + } + `} `; /** @@ -38,12 +59,17 @@ export function MasterMix({ pipettingLoss, ingredients, perReactionIngredients, + bodyPadding, + headerSpacing, + indentStepInPixels, }: MasterMixProps) { /** Keyed on the scaling data itself, so a caller without types degrades to the recipe. */ const scaling = pipettingLoss != null ? { count, pipettingLoss } : undefined; return ( - {name}{' '} @@ -58,8 +84,9 @@ export function MasterMix({ ingredients={ingredients} perReactionIngredients={perReactionIngredients} scaling={scaling} + indentStepInPixels={indentStepInPixels} /> - + ); } diff --git a/src/MasterMix/pipettingLossTableColumn.tsx b/src/MasterMix/pipettingLossTableColumn.tsx index a634a6c2..55bc45c5 100644 --- a/src/MasterMix/pipettingLossTableColumn.tsx +++ b/src/MasterMix/pipettingLossTableColumn.tsx @@ -96,7 +96,7 @@ export function pipettingLossTableColumn( return <>–; case 'masterMixIngredient': case 'masterMixTotal': - return <>{totalVolume(record, args)} µl; + return <>{totalVolume(record, args)} µl; } }, }; diff --git a/src/MasterMix/types.ts b/src/MasterMix/types.ts index 1a909a87..fdcc76ba 100644 --- a/src/MasterMix/types.ts +++ b/src/MasterMix/types.ts @@ -1,6 +1,6 @@ import { Modify } from '@mll-lab/js-utils'; import { ColumnType } from 'antd/es/table'; -import { ReactNode } from 'react'; +import { CSSProperties, ReactNode } from 'react'; export type MasterMixIngredient = { key: number; @@ -31,9 +31,16 @@ export type RecipeMode = { pipettingLoss?: never; }; +export type MasterMixStyle = { + bodyPadding?: CSSProperties['padding']; + headerSpacing?: CSSProperties['paddingBlock']; + indentStepInPixels?: number; +}; + export type MasterMixProps = { name: string; } & ReactionMix & + MasterMixStyle & (PipettingScaling | RecipeMode); export type PipettingLossAbsolute = { type: 'absolute'; count: number }; diff --git a/src/MasterMix/volumeColumns.tsx b/src/MasterMix/volumeColumns.tsx index 31f49218..6fd89022 100644 --- a/src/MasterMix/volumeColumns.tsx +++ b/src/MasterMix/volumeColumns.tsx @@ -5,6 +5,7 @@ import { REFERENCE_VOLUME_CLASS } from './VolumeTable'; import { indentLevel } from './indentLevel'; import { pipettingLossTableColumn } from './pipettingLossTableColumn'; import { + MasterMixStyle, MasterMixTableRow, PipettingLossTableColumn, PipettingScaling, @@ -18,11 +19,17 @@ function belongsToMasterMix(record: MasterMixTableRow): boolean { ); } -export function volumeColumns( - scaling: PipettingScaling | undefined, - pipettedKeys: Array, - withinReactionMix: boolean, -): Array { +export function volumeColumns({ + scaling, + pipettedKeys, + withinReactionMix, + indentStepInPixels, +}: { + scaling: PipettingScaling | undefined; + pipettedKeys: Array; + withinReactionMix: boolean; + indentStepInPixels: MasterMixStyle['indentStepInPixels']; +}): Array { return [ { title: 'Name', @@ -30,6 +37,7 @@ export function volumeColumns( {record.title} @@ -45,7 +53,7 @@ export function volumeColumns( : undefined, }), render: (_: unknown, record: MasterMixTableRow) => ( - <>{record.volume.toFixed(1)} µl + <>{record.volume.toFixed(1)} µl ), }, ...(scaling ? [pipettingLossTableColumn(scaling)] : []),