Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"lint": "npm run lint:ts && npm run lint:scss",
"lint:ts": "biome lint --write src",
"lint:scss": "stylelint 'src/**/*.{css,scss}' --allow-empty-input --fix",
"format": "biome format --write src",
"format": "biome check --write",
"dev": "storybook dev -p 3020",
"test": "jest --cache",
"test:watch": "npm run test -- --watch",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
export enum AudioWaveFormDisplaySize {
Small = 'small',
Large = 'large',
}

export interface WaveFormBar {
/** Horizontal position of the bar (viewBox units). Shared by both endpoints since the bar is a vertical line. */
x: number;
/** Y-coordinate of the bar's top endpoint (viewBox units). */
yTop: number;
/** Y-coordinate of the bar's bottom endpoint (viewBox units). */
yBottom: number;
}

// Bar geometry traced from the original design asset.
const WAVE_FORM_VIEW_BOX_WIDTH = 92;
const WAVE_FORM_VIEW_BOX_HEIGHT = 44;
const WAVE_FORM_CENTER_Y = 21.6;
const WAVE_FORM_FIRST_BAR_X = 0.9;
const WAVE_FORM_BAR_SPACING = 3;
export const WAVE_FORM_STROKE_WIDTH = 1.8;

// Padding around the bars, baked into the viewBox rather than CSS padding so it can't collapse
// to zero on a short/narrow container.
const WAVE_FORM_PADDING_X_RATIO = 0.15;
const WAVE_FORM_PADDING_Y_RATIO = 0.3;

// Exported so PeakDisplay.tsx can account for this padding in its own clip-path math - otherwise
// the played/unplayed reveal "plays through" blank space before any bar is visible.
export const WAVE_FORM_PADDING_X_PERCENT = WAVE_FORM_PADDING_X_RATIO * 100;

// Half the height of each bar (viewBox units), left to right, traced from the original asset.
const WAVE_FORM_BAR_HALF_HEIGHTS: readonly number[] = [
0.3, 3.3, 3.3, 6.9, 3.3, 6.9, 13.5, 20.7, 10.5, 6.9, 17.1, 13.5, 10.5, 3.3, 6.9, 3.3, 3.3, 6.9,
10.5, 13.5, 6.9, 3.3, 3.3, 6.9, 3.3, 3.3, 6.9, 3.3, 3.3, 1.5,
];

const WAVE_FORM_BAR_COUNT = WAVE_FORM_BAR_HALF_HEIGHTS.length;

// Right margin the reference asset leaves after its last bar, reused to size the large viewBox.
const WAVE_FORM_RIGHT_MARGIN =
WAVE_FORM_VIEW_BOX_WIDTH -
(WAVE_FORM_FIRST_BAR_X + (WAVE_FORM_BAR_COUNT - 1) * WAVE_FORM_BAR_SPACING);

function buildWaveFormBars(
barCount: number,
halfHeightAt: (index: number) => number
): WaveFormBar[] {
return Array.from({ length: barCount }, (_, index) => {
const x = WAVE_FORM_FIRST_BAR_X + index * WAVE_FORM_BAR_SPACING;
const halfHeight = halfHeightAt(index);
return { x, yTop: WAVE_FORM_CENTER_Y - halfHeight, yBottom: WAVE_FORM_CENTER_Y + halfHeight };
});
}

function getWaveFormViewBoxWidth(barCount: number): number {
return WAVE_FORM_FIRST_BAR_X + (barCount - 1) * WAVE_FORM_BAR_SPACING + WAVE_FORM_RIGHT_MARGIN;
}

const SMALL_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars(
WAVE_FORM_BAR_COUNT,
(index) => WAVE_FORM_BAR_HALF_HEIGHTS[index]
);

// Large: the small waveform immediately followed by its own mirror, on one continuous grid.
const LARGE_WAVE_FORM_BARS: readonly WaveFormBar[] = buildWaveFormBars(
WAVE_FORM_BAR_COUNT * 2,
(index) =>
WAVE_FORM_BAR_HALF_HEIGHTS[
index < WAVE_FORM_BAR_COUNT ? index : WAVE_FORM_BAR_COUNT * 2 - 1 - index
]
);

export function getWaveFormBars(size: AudioWaveFormDisplaySize): readonly WaveFormBar[] {
return size === AudioWaveFormDisplaySize.Large ? LARGE_WAVE_FORM_BARS : SMALL_WAVE_FORM_BARS;
}

// Expands the bars' bounding box to the full display box, per the padding ratios above.
export function getWaveFormViewBox(size: AudioWaveFormDisplaySize): string {
const contentWidth = getWaveFormViewBoxWidth(getWaveFormBars(size).length);
const contentHeight = WAVE_FORM_VIEW_BOX_HEIGHT;

const width = contentWidth / (1 - 2 * WAVE_FORM_PADDING_X_RATIO);
const height = contentHeight / (1 - 2 * WAVE_FORM_PADDING_Y_RATIO);
const minX = -(width - contentWidth) / 2;
const minY = -(height - contentHeight) / 2;
return `${minX} ${minY} ${width} ${height}`;
}
22 changes: 22 additions & 0 deletions src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.c-audio-wave-form-display {
display: block;
width: 100%;
height: 100%;
background-color: var(--c-audio-wave-form-display-bg, transparent);

&__scaler {
display: block;
width: 100%;
height: 100%;
}

&__svg {
display: block;
width: 100%;
height: 100%;
}

&__bar {
stroke: var(--c-audio-wave-form-display-bar-color, var(--c-audio-wave-form-display-wave-color, #fff));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { AudioWaveFormDisplay } from './AudioWaveFormDisplay';
import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers';

const meta: Meta<typeof AudioWaveFormDisplay> = {
title: 'Components/AudioWaveFormDisplay',
component: AudioWaveFormDisplay,
};
export default meta;
type Story = StoryObj<typeof AudioWaveFormDisplay>;

export const Default: Story = {
render: () => (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
background: '#111',
padding: '1rem',
}}
>
<div style={{ height: '4rem' }}>
<AudioWaveFormDisplay
ariaLabel="Waveform"
size={AudioWaveFormDisplaySize.Small}
waveColor="#fff"
/>
</div>
<div style={{ height: '4rem' }}>
<AudioWaveFormDisplay
ariaLabel="Waveform"
size={AudioWaveFormDisplaySize.Large}
waveColor="#00c8aa"
/>
</div>
</div>
),
args: {},
};

export const CustomColors: Story = {
render: () => (
<div style={{ height: '4rem' }}>
<AudioWaveFormDisplay ariaLabel="Waveform" waveColor="#ff6b6b" backgroundColor="#1d1d1d" />
</div>
),
args: {},
};
51 changes: 51 additions & 0 deletions src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cleanup, render } from '@testing-library/react';

import { AudioWaveFormDisplay } from './AudioWaveFormDisplay';
import { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers';

afterEach(() => {
cleanup();
});

describe('<AudioWaveFormDisplay />', () => {
it('should be able to render', () => {
render(<AudioWaveFormDisplay />);
});

it('should render the large size with twice as many bars as the small size', () => {
const { container: small } = render(
<AudioWaveFormDisplay size={AudioWaveFormDisplaySize.Small} />
);
const { container: large } = render(
<AudioWaveFormDisplay size={AudioWaveFormDisplaySize.Large} />
);

const smallBarCount = small.querySelectorAll('.c-audio-wave-form-display__bar').length;
const largeBarCount = large.querySelectorAll('.c-audio-wave-form-display__bar').length;

expect(largeBarCount).toBe(smallBarCount * 2);
});

it('should leave the wave color CSS variable unset when none is given, falling back to the SCSS default', () => {
const { container } = render(<AudioWaveFormDisplay />);
const outer = container.querySelector('.c-audio-wave-form-display') as HTMLElement;

expect(outer.style.getPropertyValue('--c-audio-wave-form-display-wave-color')).toBe('');
});

it('should pass the given wave and background colors through as CSS variables', () => {
const { container } = render(
<AudioWaveFormDisplay waveColor="#00c8aa" backgroundColor="#1d1d1d" />
);
const outer = container.querySelector('.c-audio-wave-form-display') as HTMLElement;

expect(outer.style.getPropertyValue('--c-audio-wave-form-display-wave-color')).toBe('#00c8aa');
expect(outer.style.getPropertyValue('--c-audio-wave-form-display-bg')).toBe('#1d1d1d');
});

it('should pass the given className through', () => {
const { container } = render(<AudioWaveFormDisplay className="my-extra-class" />);

expect(container.querySelector('.c-audio-wave-form-display.my-extra-class')).not.toBeNull();
});
});
67 changes: 67 additions & 0 deletions src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import clsx from 'clsx';
Comment thread
reunefe marked this conversation as resolved.
import { type CSSProperties, type FC, memo } from 'react';
import {
AudioWaveFormDisplaySize,
getWaveFormBars,
getWaveFormViewBox,
WAVE_FORM_STROKE_WIDTH,
} from './AudioWaveFormDisplay.helpers';
import type { AudioWaveFormDisplayProps } from './AudioWaveFormDisplay.types';

import './AudioWaveFormDisplay.scss';

// Memoized so PeakDisplay's static "inactive" waveform layer (unchanging colors/size) skips
// reconciling its ~30-60 <line> elements on every playback timeupdate tick, which only changes
// the sibling "active" layer's clip-path, not either layer's own props.
export const AudioWaveFormDisplay: FC<AudioWaveFormDisplayProps> = memo(
function AudioWaveFormDisplay({
className,
rootClassName: root = 'c-audio-wave-form-display',
ariaLabel,
waveColor,
backgroundColor,
size = AudioWaveFormDisplaySize.Small,
}) {
const bars = getWaveFormBars(size);
const viewBox = getWaveFormViewBox(size);

return (
<div
role="img"
aria-label={ariaLabel}
className={clsx(root, `${root}--${size}`, className)}
style={
{
'--c-audio-wave-form-display-bg': backgroundColor,
'--c-audio-wave-form-display-wave-color': waveColor,
} as CSSProperties
}
>
{/* Plain box for consumers to hook a hover-zoom transform onto: transitioning `transform`
on an <svg> itself doesn't animate smoothly in every browser, unlike an ordinary element. */}
<div className="c-audio-wave-form-display__scaler">
<svg
className="c-audio-wave-form-display__svg"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
{bars.map((bar, index) => (
<line
// biome-ignore lint/suspicious/noArrayIndexKey: decorative, no identity of its own
key={index}
className="c-audio-wave-form-display__bar"
x1={bar.x}
x2={bar.x}
y1={bar.yTop}
y2={bar.yBottom}
strokeWidth={WAVE_FORM_STROKE_WIDTH}
strokeLinecap="round"
/>
))}
</svg>
</div>
</div>
);
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { DefaultComponentProps } from '../../types';
import type { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers';

export type AudioWaveFormDisplayProps = DefaultComponentProps & {
waveColor?: string;
backgroundColor?: string;
size?: AudioWaveFormDisplaySize;
ariaLabel?: string;
};
6 changes: 6 additions & 0 deletions src/components/AudioWaveFormDisplay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { AudioWaveFormDisplay } from './AudioWaveFormDisplay';
export {
AudioWaveFormDisplaySize,
WAVE_FORM_PADDING_X_PERCENT,
} from './AudioWaveFormDisplay.helpers';
export * from './AudioWaveFormDisplay.types';
26 changes: 26 additions & 0 deletions src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,30 @@ describe('<Dropdown />', () => {
expect(dropdownFullWidthRoot).not.toHaveClass('c-dropdown__trigger');
expect(dropdownFitContentRoot).toHaveClass('c-dropdown__trigger');
});

it('Should render correctly with `shiftPadding` unset (default, unshifted positioning)', async () => {
// Regression: `shift` middleware used to be added unconditionally for every Dropdown
// consumer, silently changing positioning behaviour for consumers that never opted in.
const label = 'Show options';
const children = <div>content item</div>;
const { container } = renderDropdown({ children, label, isOpen: true, id: 'test-id-5' });

const dropdownContent = await waitFor(() => container.querySelector('.c-dropdown'));
expect(dropdownContent).toBeInTheDocument();
});

it('Should render correctly with `shiftPadding` set', async () => {
const label = 'Show options';
const children = <div>content item</div>;
const { container } = renderDropdown({
children,
label,
isOpen: true,
shiftPadding: 8,
id: 'test-id-6',
});

const dropdownContent = await waitFor(() => container.querySelector('.c-dropdown'));
expect(dropdownContent).toBeInTheDocument();
});
});
28 changes: 27 additions & 1 deletion src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
autoUpdate,
offset as offsetHelper,
shift,
size,
useClick,
useDismiss,
useFloating,
Expand Down Expand Up @@ -49,6 +51,8 @@ const Dropdown: FC<DropdownProps> = ({ children, ...props }) => {
variants,
isDisabled,
offset = 10,
shiftPadding,
maxHeightPadding,
} = props;
const { refs, floatingStyles, context } = useFloating({
placement,
Expand All @@ -57,7 +61,29 @@ const Dropdown: FC<DropdownProps> = ({ children, ...props }) => {
open ? onOpen() : onClose();
},
whileElementsMounted: autoUpdate,
middleware: [offsetHelper(offset)],
middleware: [
offsetHelper(offset),
// `shift` nudges the flyout back within its clipping ancestor near an edge, instead of
// letting it get clipped. Opt-in via `shiftPadding` so other consumers are unaffected.
...(shiftPadding !== undefined ? [shift({ padding: shiftPadding })] : []),
// Caps the flyout to whatever space is actually available in its clipping ancestor (e.g.
// a small video player) and viewport, scrolling its own content instead of overflowing -
// mirrors Flowplayer's native menu (`.fp-menu ol { max-height: 80%; overflow-y: auto }`).
// Opt-in via `maxHeightPadding` so other consumers are unaffected.
...(maxHeightPadding !== undefined
? [
size({
padding: maxHeightPadding,
apply({ availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxHeight: `${availableHeight}px`,
overflowY: 'auto',
});
},
}),
]
: []),
],
});

const click = useClick(context);
Expand Down
Loading