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
20 changes: 12 additions & 8 deletions packages/react/components/calendar/range-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
Expand Down Expand Up @@ -110,11 +111,16 @@ export function RangePicker({

const [currentRangeField, setCurrentRangeField] =
useState<RangeFields>('from');
const [internalValue, setInternalValue] = useState<DateRange | undefined>(
value ?? defaultValue
);
const [computedValue, setValueUnwrapped] = useControlled<
DateRange | undefined
>({
controlled: value,
default: defaultValue,
name: 'RangePicker',
state: 'value'
});
const [currentMonth, setCurrentMonth] = useState<Date | undefined>(
internalValue?.from
computedValue?.from
);

/*
Expand All @@ -132,8 +138,7 @@ export function RangePicker({
}, [valueFromTime, isControlled]);

// Empty-range fallback so downstream `.from`/`.to` reads don't need guards.
const selectedRange: DateRange = value ??
internalValue ?? { from: undefined };
const selectedRange: DateRange = computedValue ?? { from: undefined };

const startDate = selectedRange.from
? dayjs(selectedRange.from).format(dateFormat)
Expand Down Expand Up @@ -208,8 +213,7 @@ export function RangePicker({
}

if (newField !== currentRangeField) setCurrentRangeField(newField);
// Only update internal state when uncontrolled, since controlled consumers own `value`.
if (!isControlled) setInternalValue(newRange);
setValueUnwrapped(newRange);
onSelect(newRange);
if (shouldClose) popover.disengage();
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { ComponentProps, StrictMode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { CodeBlock } from '../code-block';

Expand Down Expand Up @@ -147,6 +147,31 @@ describe('CodeBlock', () => {
expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.queryByText('function')).not.toBeInTheDocument();
});

it('shows the language of the code when no value is set', () => {
render(
<CodeBlock>
<CodeBlock.Header>
<CodeBlock.LanguageSelect>
<CodeBlock.LanguageSelectTrigger />
<CodeBlock.LanguageSelectContent>
<CodeBlock.LanguageSelectItem value='jsx'>
JavaScript
</CodeBlock.LanguageSelectItem>
<CodeBlock.LanguageSelectItem value='python'>
Python
</CodeBlock.LanguageSelectItem>
</CodeBlock.LanguageSelectContent>
</CodeBlock.LanguageSelect>
</CodeBlock.Header>
<CodeBlock.Content>
<CodeBlock.Code language='python'>{PYTHON_CODE}</CodeBlock.Code>
</CodeBlock.Content>
</CodeBlock>
);

expect(screen.getByRole('combobox')).toHaveTextContent('Python');
});
});

describe('Copy Button', () => {
Expand Down Expand Up @@ -197,6 +222,45 @@ describe('CodeBlock', () => {
).toHaveLength(2);
});

it('reports the next state when collapsed is controlled', async () => {
const user = userEvent.setup();
const onCollapseChange = vi.fn();
render(
<BasicCodeBlock
maxLines={2}
hasCollapseTrigger
collapsed={false}
onCollapseChange={onCollapseChange}
/>
);

await user.click(screen.getByText('Hide Code'));

expect(onCollapseChange).toHaveBeenCalledTimes(1);
expect(onCollapseChange).toHaveBeenCalledWith(true);
expect(screen.getByText('Hide Code')).toBeInTheDocument();
});

it('calls onCollapseChange once per click in StrictMode', async () => {
const user = userEvent.setup();
const onCollapseChange = vi.fn();
render(
<StrictMode>
<BasicCodeBlock
maxLines={2}
hasCollapseTrigger
onCollapseChange={onCollapseChange}
/>
</StrictMode>
);

await user.click(screen.getByText('Show Code'));

expect(onCollapseChange).toHaveBeenCalledTimes(1);
expect(onCollapseChange).toHaveBeenCalledWith(false);
expect(screen.getByText('Hide Code')).toBeInTheDocument();
});

it('hides collapse trigger when code fits in maxLines', () => {
render(<BasicCodeBlock maxLines={3} hasCollapseTrigger />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export const CodeBlockLanguageSelect = (
const handleValueChange = (value: string) => {
setValue(value as Language);
};
return <Select onValueChange={handleValueChange} value={value} {...props} />;
// The value is unset until a CodeBlock.Code sets it. Pass '' so the Select
// is controlled from its first render.
return (
<Select onValueChange={handleValueChange} value={value ?? ''} {...props} />
);
};

CodeBlockLanguageSelect.displayName = 'CodeBlock.LanguageSelect';
Expand Down
36 changes: 19 additions & 17 deletions packages/react/components/code-block/code-block-root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
import {
ComponentProps,
Expand Down Expand Up @@ -60,32 +61,33 @@ export const CodeBlockRoot = ({
onCollapseChange,
...props
}: CodeBlockProps) => {
const [internalValue, setInternalValue] = useState<string | undefined>(
defaultValue
);
const [value, setValueUnwrapped] = useControlled<string | undefined>({
controlled: providedValue,
default: defaultValue,
name: 'CodeBlock',
state: 'value'
});
const [code, setCode] = useState<string | undefined>();
const [internalCollapsed, setInternalCollapsed] = useState<boolean>(
defaultCollapsed ?? false
);

const collapsed = providedCollapsed ?? internalCollapsed;

const value = providedValue ?? internalValue ?? defaultValue;
const [collapsed, setCollapsedUnwrapped] = useControlled({
controlled: providedCollapsed,
default: defaultCollapsed,
name: 'CodeBlock',
state: 'collapsed'
});

const setValue = useCallback(
(newValue: string) => {
setInternalValue(newValue);
setValueUnwrapped(newValue);
onValueChange?.(newValue);
},
[onValueChange]
[onValueChange, setValueUnwrapped]
);

const toggleCollapsed = useCallback(() => {
setInternalCollapsed(_internalCollapsed => {
onCollapseChange?.(!_internalCollapsed);
return !_internalCollapsed;
});
}, [onCollapseChange]);
const next = !collapsed;
setCollapsedUnwrapped(next);
onCollapseChange?.(next);
}, [collapsed, onCollapseChange, setCollapsedUnwrapped]);

return (
<CodeBlockContext
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ColorPicker } from '../color-picker';
Expand Down Expand Up @@ -428,6 +429,24 @@ describe('ColorPicker', () => {
expect(modeSelector).toHaveTextContent('HEX');
});

it('switches an uncontrolled mode and calls onModeChange', async () => {
const user = userEvent.setup();
const onModeChange = vi.fn();
render(
<ColorPicker defaultValue='#ff0000' onModeChange={onModeChange}>
<ColorPicker.Input data-testid='color-input' />
<ColorPicker.Mode data-testid='mode-selector' />
</ColorPicker>
);

await user.click(screen.getByTestId('mode-selector'));
await user.click(await screen.findByRole('option', { name: 'RGB' }));

expect(onModeChange).toHaveBeenCalledWith('rgb');
expect(screen.getByTestId('mode-selector')).toHaveTextContent('RGB');
expect(screen.getByTestId('color-input')).toHaveValue('rgb(255, 0, 0)');
});

it('accepts custom options', () => {
const customOptions: Array<'hex' | 'rgb'> = ['hex', 'rgb'];

Expand Down
41 changes: 21 additions & 20 deletions packages/react/components/color-picker/color-picker-root.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import { useControlled } from '@base-ui/utils/useControlled';
import {
ComponentProps,
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState
useRef
} from 'react';
import { Flex } from '../flex';
import {
Expand Down Expand Up @@ -64,25 +64,26 @@ export const ColorPickerRoot = ({
[value]
);

const [internalColor, setInternalColor] = useState(() =>
parseColor(defaultValue)
);
const [internalMode, setInternalMode] = useState(defaultMode);

const mode = providedMode ?? internalMode;
const [color, setColorUnwrapped] = useControlled({
controlled: providedColor,
default: parseColor(defaultValue),
name: 'ColorPicker',
state: 'value'
});
const [mode, setModeUnwrapped] = useControlled({
controlled: providedMode,
default: defaultMode,
name: 'ColorPicker',
state: 'mode'
});

// In non-oklch modes, derive a display value clamped to the sRGB gamut so
// the pad / thumb / input only reflect colors the active output mode can
// actually represent. Internal state stays raw so switching back to oklch
// restores any wide-gamut pick the user hadn't yet overwritten.
const rawColor = useMemo<ColorObject>(
() => ({
l: providedColor ? providedColor.l : internalColor.l,
c: providedColor ? providedColor.c : internalColor.c,
h: providedColor ? providedColor.h : internalColor.h,
alpha: (providedColor ? providedColor.alpha : internalColor.alpha) ?? 1
}),
[providedColor, internalColor]
() => ({ ...color, alpha: color.alpha ?? 1 }),
[color]
);
// clampToSrgb wraps culori's clampChroma, which is iterative, so memoize
// it doesn't re-run on unrelated re-renders during a drag.
Expand All @@ -98,26 +99,26 @@ export const ColorPickerRoot = ({

// Mirror the current effective color in a ref so setColor can compute the
// next value synchronously, without putting onValueChange inside the
// setInternalColor updater (where StrictMode would fire it twice).
// state updater (where StrictMode would fire it twice).
const rawColorRef = useRef(rawColor);
rawColorRef.current = rawColor;

const setColor = useCallback<ColorPickerContextValue['setColor']>(
value => {
const next = { ...rawColorRef.current, ...value };
rawColorRef.current = next;
setInternalColor(next);
setColorUnwrapped(next);
onValueChange?.(getColorString(next, mode), mode);
},
[mode, onValueChange]
[mode, onValueChange, setColorUnwrapped]
);

const setMode = useCallback<ColorPickerContextValue['setMode']>(
value => {
setInternalMode(value);
setModeUnwrapped(value);
onModeChange?.(value);
},
[onModeChange]
[onModeChange, setModeUnwrapped]
);

// Memoize the context value so consumers (Area, Hue, Alpha, Input, Mode) only
Expand Down
Loading
Loading