import React from 'react'; import PropTypes from 'prop-types'; import { BASE_CLASS_PREFIX } from '@douyinfe/semi-foundation/base/constants'; import warning from '@douyinfe/semi-foundation/utils/warning'; import DefaultLocale from '../locale/source/zh_CN'; import Context, { ContextValue } from './context'; import { registerMediaQuery } from '../_utils'; import { ResponsiveMap, BreakpointScreens, OnBreakpointScreensCallback, OnBreakpointChangeCallback, Breakpoint, } from './responsiveTypes'; export interface ConfigProviderProps extends Omit { /** * Custom responsive map configuration * If not provided, default responsive map will be used */ responsiveMap?: ResponsiveMap; /** * Enable responsive observing in ConfigProvider. * * - When false (default): ConfigProvider will not register any matchMedia listeners. * - When true: listeners will be registered lazily on first subscription. */ responsiveObserve?: boolean } interface ConfigProviderState { screens: BreakpointScreens } /** * Default responsive map configuration * Can be accessed via ConfigProvider.defaultResponsiveMap */ export const defaultResponsiveMap: ResponsiveMap = { xs: '(max-width: 575px)', sm: '(min-width: 576px)', md: '(min-width: 768px)', lg: '(min-width: 992px)', xl: '(min-width: 1200px)', xxl: '(min-width: 1600px)', }; export const ConfigConsumer = Context.Consumer; export default class ConfigProvider extends React.Component { static propTypes = { locale: PropTypes.object, timeZone: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), getPopupContainer: PropTypes.func, direction: PropTypes.oneOf(['ltr', 'rtl']), responsiveMap: PropTypes.object, responsiveObserve: PropTypes.bool, }; static defaultProps = { locale: DefaultLocale, direction: 'ltr', responsiveObserve: false, }; /** * Default responsive map - static property for backward compatibility */ static defaultResponsiveMap = defaultResponsiveMap; private unRegisters: Array<() => void> = []; private hasRegisteredMediaQueries = false; private hasWarnedResponsiveObserve = false; private screensListeners: Set = new Set(); private changeListeners: Set<{ breakpoints?: Breakpoint[]; callback: OnBreakpointChangeCallback }> = new Set(); /** * Synchronous source of truth for current breakpoint matches. * `setState` is async, so reading `this.state.screens` immediately after * registering listeners returns stale values. Subscriber callbacks read * from this ref to always get the freshest snapshot. */ private currentScreensRef: BreakpointScreens | null = null; constructor(props: ConfigProviderProps) { super(props); this.state = { screens: { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false, }, }; } componentDidMount() { // lazy register on demand (first subscription) } componentDidUpdate(prevProps: ConfigProviderProps) { // If toggle switched off, ensure unregister if (prevProps.responsiveObserve && !this.props.responsiveObserve) { this.unregisterMediaQueries(); } // Re-register media queries if responsiveMap changes (only if already registered) if (this.hasRegisteredMediaQueries && prevProps.responsiveMap !== this.props.responsiveMap) { this.unregisterMediaQueries(); this.registerMediaQueries(); } // If toggle switched on and there are existing subscriptions, ensure register if (!prevProps.responsiveObserve && this.props.responsiveObserve) { this.ensureMediaQueriesRegistered(); } } componentWillUnmount() { this.unregisterMediaQueries(); this.screensListeners.clear(); this.changeListeners.clear(); } private ensureMediaQueriesRegistered = () => { if (!this.props.responsiveObserve) { if (!this.hasWarnedResponsiveObserve) { this.hasWarnedResponsiveObserve = true; const shouldWarn = typeof process !== 'undefined' && !!process.env && process.env.NODE_ENV !== 'production'; warning( shouldWarn, '[Semi] ConfigProvider responsive observing is disabled by default. ' + 'Set to enable breakpoint subscriptions.' ); } return; } const hasAnySubscriber = this.screensListeners.size > 0 || this.changeListeners.size > 0; if (!hasAnySubscriber) { return; } if (this.hasRegisteredMediaQueries) { return; } this.registerMediaQueries(); }; /** * Register media query listeners. * * To avoid stale-state bug in immediate subscriber callbacks, we * synchronously read all `matchMedia(...).matches` once *before* attaching * the change listeners, write the result to both `currentScreensRef` and * `state.screens` in a single batched setState, and only then start * tracking changes (with `callInInit: false` so we don't double-fire). */ private registerMediaQueries = () => { if (this.hasRegisteredMediaQueries) { return; } const responsiveMap = this.props.responsiveMap || defaultResponsiveMap; const breakpointKeys = Object.keys(responsiveMap) as Array; const initialScreens: BreakpointScreens = { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false, }; if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { breakpointKeys.forEach(screen => { initialScreens[screen] = window.matchMedia(responsiveMap[screen]).matches; }); } this.currentScreensRef = initialScreens; this.unRegisters = breakpointKeys.map(screen => registerMediaQuery(responsiveMap[screen], { match: () => { this.updateScreen(screen, true); }, unmatch: () => { this.updateScreen(screen, false); }, callInInit: false, }) ); this.hasRegisteredMediaQueries = true; this.setState({ screens: initialScreens }); }; /** * Unregister all media query listeners */ private unregisterMediaQueries = () => { this.unRegisters.forEach(unRegister => unRegister()); this.unRegisters = []; this.hasRegisteredMediaQueries = false; this.currentScreensRef = null; }; /** * Update screen state and notify listeners */ private updateScreen = (screen: keyof ResponsiveMap, matches: boolean) => { if (this.currentScreensRef && this.currentScreensRef[screen] !== matches) { this.currentScreensRef = { ...this.currentScreensRef, [screen]: matches }; } this.setState(prevState => { if (prevState.screens[screen] === matches) { return null; } return { screens: { ...prevState.screens, [screen]: matches, }, }; }, () => { this.notifyListeners(screen as Breakpoint, matches); }); }; /** * Notify all registered listeners */ private notifyListeners = (changedScreen?: Breakpoint, match?: boolean) => { const { screens } = this.state; // full screens subscriptions this.screensListeners.forEach(listener => { listener(screens); }); // single change subscriptions if (changedScreen != null && match != null) { this.changeListeners.forEach(({ breakpoints, callback }) => { if (!breakpoints || breakpoints.includes(changedScreen)) { callback(changedScreen, match); } }); } }; /** * Subscribe to breakpoint changes * @param callback Function to call when breakpoint changes * @returns Unsubscribe function */ private handleBreakpoint: { (callback: OnBreakpointScreensCallback): () => void; (breakpoints: Breakpoint[], callback: OnBreakpointChangeCallback): () => void } = (arg1: any, arg2?: any) => { // onBreakpoint(callback) if (typeof arg1 === 'function') { const cb: OnBreakpointScreensCallback = arg1; this.screensListeners.add(cb); this.ensureMediaQueriesRegistered(); // Read from currentScreensRef so we deliver the freshly-computed // matches (set synchronously inside registerMediaQueries) instead // of the still-async this.state.screens. const initialScreens = this.currentScreensRef ?? this.state.screens; cb(initialScreens); return () => { this.screensListeners.delete(cb); if (this.props.responsiveObserve && this.screensListeners.size === 0 && this.changeListeners.size === 0) { this.unregisterMediaQueries(); } }; } // onBreakpoint(breakpoints, callback) const breakpoints: Breakpoint[] = Array.isArray(arg1) ? arg1 : undefined; const cb: OnBreakpointChangeCallback = arg2; const entry = { breakpoints, callback: cb }; this.changeListeners.add(entry); this.ensureMediaQueriesRegistered(); const initialScreens = this.currentScreensRef ?? this.state.screens; if (breakpoints && typeof cb === 'function') { breakpoints.forEach(bp => { cb(bp, initialScreens[bp]); }); } return () => { this.changeListeners.delete(entry); // if no subscribers remain, unregister to save resources if (this.props.responsiveObserve && this.screensListeners.size === 0 && this.changeListeners.size === 0) { this.unregisterMediaQueries(); } }; }; renderChildren() { const { direction, children } = this.props; if (direction === 'rtl') { return (
{children}
); } return children; } render() { const { children, direction, responsiveMap, ...rest } = this.props; const { screens } = this.state; return ( {this.renderChildren()} ); } } // Export types for external use export type { ResponsiveMap, BreakpointScreens, Breakpoint, OnBreakpointScreensCallback, OnBreakpointChangeCallback, } from './responsiveTypes';