Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export type AudioWaveFormDisplaySize = 'small' | 'large';
Comment thread
reunefe marked this conversation as resolved.

export interface WaveFormBar {
x: number;
y1: number;
y2: number;
Comment thread
reunefe marked this conversation as resolved.
}

// 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, y1: WAVE_FORM_CENTER_Y - halfHeight, y2: 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 === '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,33 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { AudioWaveFormDisplay } from './AudioWaveFormDisplay';

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="small" waveColor="#fff" />
</div>
<div style={{ height: '4rem' }}>
<AudioWaveFormDisplay ariaLabel="Waveform" size="large" waveColor="#00c8aa" />
</div>
</div>
),
args: {},
};

export const CustomColors: Story = {
render: () => (
<div style={{ height: '4rem' }}>
<AudioWaveFormDisplay ariaLabel="Waveform" waveColor="#ff6b6b" backgroundColor="#1d1d1d" />
</div>
),
args: {},
};
60 changes: 60 additions & 0 deletions src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import clsx from 'clsx';
import { type CSSProperties, type FC, memo } from 'react';
import { 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 = '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.y1}
y2={bar.y2}
strokeWidth={WAVE_FORM_STROKE_WIDTH}
strokeLinecap="round"
/>
))}
</svg>
</div>
</div>
);
});
11 changes: 11 additions & 0 deletions src/components/AudioWaveFormDisplay/AudioWaveFormDisplay.types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { DefaultComponentProps } from '../../types';
import type { AudioWaveFormDisplaySize } from './AudioWaveFormDisplay.helpers';

export type { AudioWaveFormDisplaySize };

export type AudioWaveFormDisplayProps = DefaultComponentProps & {
waveColor?: string;
backgroundColor?: string;
size?: AudioWaveFormDisplaySize;
ariaLabel?: string;
};
3 changes: 3 additions & 0 deletions src/components/AudioWaveFormDisplay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { AudioWaveFormDisplay } from './AudioWaveFormDisplay';
export { 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
14 changes: 14 additions & 0 deletions src/components/Dropdown/Dropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ export interface DropdownProps extends DefaultComponentProps {
triggerWidth?: 'fit-content' | 'full-width';
isDisabled?: boolean;
offset?: number;
/**
* Opts into floating-ui's `shift` middleware, nudging the flyout back within its clipping
* ancestor (e.g. a container with `overflow: hidden`) instead of letting it get silently
* clipped. Off by default so existing consumers keep their current positioning unchanged; pass
* a padding value (in px) to enable it.
*/
shiftPadding?: number;
/**
* Opts into floating-ui's `size` middleware, capping the flyout's height to whatever space is
* actually available in its clipping ancestor and the viewport (scrolling its own content
* instead of overflowing past that ancestor's edge), with this many px kept clear on every
* side. Off by default so existing consumers keep their current (unconstrained) height.
*/
maxHeightPadding?: number;
}
Loading