diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f8a40ce57d..2b9ad72ac0 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -161,7 +161,7 @@ } .module-message:hover .module-message__buttons, -.module-message__buttons:has([data-state='open']) { +.module-message__buttons:has([data-axo-contextmenu-state='open']) { opacity: 1; } @@ -5046,7 +5046,7 @@ button.module-calling-participants-list__contact { } &:hover:not(:disabled, &--disabled, &--is-selected), - &[data-state='open'] { + &[data-axo-contextmenu-state='open'] { background-color: light-dark( variables.$color-gray-05, variables.$color-gray-75 diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 8be7e1e65b..c2e6cfe001 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -1099,7 +1099,7 @@ $secondary-text-color: light-dark( .Preferences__ChatFolders__ChatSelection__Item--Clickable { cursor: pointer; &:hover .Preferences__ChatFolders__ChatSelection__ItemContent, - .Preferences__ChatFolders__ChatSelection__ItemContent[data-state='open'] { + .Preferences__ChatFolders__ChatSelection__ItemContent[data-axo-contextmenu-state='open'] { background: light-dark(variables.$color-gray-02, variables.$color-gray-80); } } diff --git a/ts/axo/AriaClickable.dom.tsx b/ts/axo/AriaClickable.dom.tsx index f9aa7dab32..0d3057bcc3 100644 --- a/ts/axo/AriaClickable.dom.tsx +++ b/ts/axo/AriaClickable.dom.tsx @@ -1,17 +1,14 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { - createContext, - memo, - useCallback, - useContext, - useRef, - useState, -} from 'react'; +import React, { memo, useCallback, useRef, useState } from 'react'; import type { ReactNode, MouseEvent, FC } from 'react'; import { useLayoutEffect } from '@react-aria/utils'; import { tw } from './tw.dom.js'; import { assert } from './_internal/assert.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; const Namespace = 'AriaClickable'; @@ -51,8 +48,8 @@ export namespace AriaClickable { type TriggerStateUpdate = (state: TriggerState) => void; - const TriggerStateUpdateContext = createContext( - null + const TriggerStateUpdateContext = createStrictContext( + `${Namespace}.Root` ); /** @@ -174,13 +171,7 @@ export namespace AriaClickable { */ export const HiddenTrigger: FC = memo(props => { const ref = useRef(null); - const onTriggerStateUpdate = useContext(TriggerStateUpdateContext); - - if (onTriggerStateUpdate == null) { - throw new Error( - `<${Namespace}.HiddenTrigger> must be wrapped with <${Namespace}.Root>` - ); - } + const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext); const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate); useLayoutEffect(() => { diff --git a/ts/axo/AxoBadge.dom.tsx b/ts/axo/AxoBadge.dom.tsx index 4a0506b670..5155b5c29e 100644 --- a/ts/axo/AxoBadge.dom.tsx +++ b/ts/axo/AxoBadge.dom.tsx @@ -31,6 +31,7 @@ export namespace ExperimentalAxoBadge { 'flex size-fit items-center justify-center-safe overflow-clip', 'rounded-full font-semibold', 'bg-color-fill-primary text-label-primary-on-color', + 'forced-color-adjust-none forced-colors:bg-[Mark] forced-colors:text-[MarkText]', 'select-none' ); diff --git a/ts/axo/AxoContextMenu.dom.tsx b/ts/axo/AxoContextMenu.dom.tsx index 51a161d8a5..2c9467c2e4 100644 --- a/ts/axo/AxoContextMenu.dom.tsx +++ b/ts/axo/AxoContextMenu.dom.tsx @@ -1,6 +1,13 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { ContextMenu } from 'radix-ui'; import type { FC, @@ -12,6 +19,10 @@ import { AxoSymbol } from './AxoSymbol.dom.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; import { tw } from './tw.dom.js'; import { assert } from './_internal/assert.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; const Namespace = 'AxoContextMenu'; @@ -55,6 +66,14 @@ const Namespace = 'AxoContextMenu'; * ``` */ export namespace AxoContextMenu { + export type RootContextType = Readonly<{ + open: boolean; + }>; + + export const RootContext = createStrictContext( + `${Namespace}.RootContext` + ); + /** * Component: * -------------------------------- @@ -63,10 +82,27 @@ export namespace AxoContextMenu { export type RootProps = AxoBaseMenu.MenuRootProps; export const Root: FC = memo(props => { + const { onOpenChange } = props; + const [open, setOpen] = useState(false); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [onOpenChange] + ); + + const context = useMemo(() => { + return { open }; + }, [open]); + return ( - - {props.children} - + + + {props.children} + + ); }); @@ -125,6 +161,7 @@ export namespace AxoContextMenu { export type TriggerProps = AxoBaseMenu.MenuTriggerProps; export const Trigger: FC = memo(props => { + const context = useStrictContext(RootContext); const [disableCurrentEvent, setDisableCurrentEvent] = useState(false); const handleContextMenuCapture = useCallback( @@ -164,7 +201,8 @@ export namespace AxoContextMenu { onContextMenu={handleContextMenu} onKeyDown={handleKeyDown} disabled={disableCurrentEvent || props.disabled} - data-axo-context-menu-trigger + data-axo-contextmenu-trigger + data-axo-contextmenu-state={context.open ? 'open' : 'closed'} > {props.children} @@ -176,7 +214,7 @@ export namespace AxoContextMenu { export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler { return useContextMenuTriggerKeyboardEventHandler(event => { return assert( - event.currentTarget.querySelector('[data-axo-context-menu-trigger]'), + event.currentTarget.querySelector('[data-axo-contextmenu-trigger]'), `Couldn't find <${Namespace}.Trigger> element, did you forget to pass all html props through?` ); }); diff --git a/ts/axo/AxoDropdownMenu.dom.tsx b/ts/axo/AxoDropdownMenu.dom.tsx index 78efc3227c..1b04a3f3f2 100644 --- a/ts/axo/AxoDropdownMenu.dom.tsx +++ b/ts/axo/AxoDropdownMenu.dom.tsx @@ -303,8 +303,7 @@ export namespace AxoDropdownMenu { const descriptionId = useId(); const { labelRef, descriptionRef } = useAriaLabellingContext( - `<${Namespace}.Header>`, - `<${Namespace}.Content/SubContent>` + `${Namespace}.Content/SubContent` ); return ( diff --git a/ts/axo/AxoMenuBuilder.dom.tsx b/ts/axo/AxoMenuBuilder.dom.tsx index c6d815df68..7694313175 100644 --- a/ts/axo/AxoMenuBuilder.dom.tsx +++ b/ts/axo/AxoMenuBuilder.dom.tsx @@ -2,24 +2,22 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { FC } from 'react'; -import React, { createContext, memo, useContext } from 'react'; +import React, { memo } from 'react'; import type { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; -import { assert, unreachable } from './_internal/assert.dom.js'; +import { unreachable } from './_internal/assert.dom.js'; import { AxoDropdownMenu } from './AxoDropdownMenu.dom.js'; import { AxoContextMenu } from './AxoContextMenu.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; const Namespace = 'AxoMenuBuilder'; export namespace AxoMenuBuilder { export type Renderer = 'AxoDropdownMenu' | 'AxoContextMenu'; - const MenuBuilderContext = createContext(null); - - // eslint-disable-next-line no-inner-declarations - function useMenuBuilderContext(): Renderer { - const context = useContext(MenuBuilderContext); - return assert(context, `Must be wrapped with <${Namespace}.Root>`); - } + const MenuBuilderContext = createStrictContext(`${Namespace}.Root`); export type RootProps = AxoBaseMenu.MenuRootProps & Readonly<{ @@ -48,7 +46,7 @@ export namespace AxoMenuBuilder { Root.displayName = `${Namespace}.Root`; export const Trigger: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -61,7 +59,7 @@ export namespace AxoMenuBuilder { Trigger.displayName = `${Namespace}.Trigger`; export const Content: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -74,7 +72,7 @@ export namespace AxoMenuBuilder { Content.displayName = `${Namespace}.Content`; export const Item: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -87,7 +85,7 @@ export namespace AxoMenuBuilder { Item.displayName = `${Namespace}.Item`; export const Group: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -100,7 +98,7 @@ export namespace AxoMenuBuilder { Group.displayName = `${Namespace}.Group`; export const Label: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -113,7 +111,7 @@ export namespace AxoMenuBuilder { Label.displayName = `${Namespace}.Label`; export const Separator: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -127,7 +125,7 @@ export namespace AxoMenuBuilder { export const CheckboxItem: FC = memo( props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -141,7 +139,7 @@ export namespace AxoMenuBuilder { CheckboxItem.displayName = `${Namespace}.CheckboxItem`; export const RadioGroup: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -154,7 +152,7 @@ export namespace AxoMenuBuilder { RadioGroup.displayName = `${Namespace}.RadioGroup`; export const RadioItem: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -167,7 +165,7 @@ export namespace AxoMenuBuilder { RadioItem.displayName = `${Namespace}.RadioItem`; export const Sub: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -180,7 +178,7 @@ export namespace AxoMenuBuilder { Sub.displayName = `${Namespace}.Sub`; export const SubTrigger: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } @@ -193,7 +191,7 @@ export namespace AxoMenuBuilder { SubTrigger.displayName = `${Namespace}.SubTrigger`; export const SubContent: FC = memo(props => { - const renderer = useMenuBuilderContext(); + const renderer = useStrictContext(MenuBuilderContext); if (renderer === 'AxoDropdownMenu') { return ; } diff --git a/ts/axo/AxoScrollArea.dom.tsx b/ts/axo/AxoScrollArea.dom.tsx index 143b625978..467f7cc733 100644 --- a/ts/axo/AxoScrollArea.dom.tsx +++ b/ts/axo/AxoScrollArea.dom.tsx @@ -1,10 +1,13 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { createContext, memo, useContext, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { CSSProperties, FC, ReactNode } from 'react'; import type { TailwindStyles } from './tw.dom.js'; import { tw } from './tw.dom.js'; -import { assert } from './_internal/assert.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; const Namespace = 'AxoScrollArea'; @@ -15,13 +18,10 @@ const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL = type AxoScrollAreaOrientation = 'vertical' | 'horizontal' | 'both'; const AxoScrollAreaOrientationContext = - createContext(null); + createStrictContext(`${Namespace}.Root`); export function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation { - return assert( - useContext(AxoScrollAreaOrientationContext), - `Must be wrapped with <${Namespace}.Root>` - ); + return useStrictContext(AxoScrollAreaOrientationContext); } /** @@ -74,15 +74,9 @@ export namespace AxoScrollArea { scrollBehavior: ScrollBehavior; }>; - const ScrollAreaConfigContext = createContext(null); - - // eslint-disable-next-line no-inner-declarations - function useAxoScrollAreaConfig(): ScrollAreaConfig { - return assert( - useContext(ScrollAreaConfigContext), - `Must be wrapped with <${Namespace}.Root>` - ); - } + const ScrollAreaConfigContext = createStrictContext( + `${Namespace}.Root` + ); /** * Component: @@ -221,7 +215,7 @@ export namespace AxoScrollArea { scrollbarGutter, scrollbarVisibility, scrollBehavior, - } = useAxoScrollAreaConfig(); + } = useStrictContext(ScrollAreaConfigContext); const style = useMemo((): CSSProperties => { const hasVerticalScrollbar = orientation !== 'horizontal'; @@ -376,7 +370,7 @@ export namespace AxoScrollArea { export const Hint: FC = memo(props => { const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props; const orientation = useAxoScrollAreaOrientation(); - const { scrollbarWidth } = useAxoScrollAreaConfig(); + const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext); const style = useMemo((): CSSProperties => { const isVerticalEdge = edge === 'top' || edge === 'bottom'; @@ -445,7 +439,7 @@ export namespace AxoScrollArea { } = props; const orientation = useAxoScrollAreaOrientation(); - const { scrollbarWidth } = useAxoScrollAreaConfig(); + const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext); const style = useMemo(() => { const hasVerticalScrollbar = orientation !== 'horizontal'; diff --git a/ts/axo/_internal/AriaLabellingContext.dom.tsx b/ts/axo/_internal/AriaLabellingContext.dom.tsx index c455214147..9c49c834ea 100644 --- a/ts/axo/_internal/AriaLabellingContext.dom.tsx +++ b/ts/axo/_internal/AriaLabellingContext.dom.tsx @@ -2,16 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { RefCallback } from 'react'; -import { createContext, useContext, useMemo, useState } from 'react'; -import { assert } from './assert.dom.js'; +import { useMemo, useState } from 'react'; +import { createStrictContext, useStrictContext } from './StrictContext.dom.js'; type AriaLabellingContextType = Readonly<{ labelRef: RefCallback; descriptionRef: RefCallback; }>; -const AriaLabellingContext = createContext( - null +const AriaLabellingContext = createStrictContext( + 'AriaLabellingContext.Provider' ); export type CreateAriaLabellingContextResult = Readonly<{ @@ -42,11 +42,10 @@ export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResul export const AriaLabellingProvider = AriaLabellingContext.Provider; export function useAriaLabellingContext( - componentName: string, providerName: string ): AriaLabellingContextType { - return assert( - useContext(AriaLabellingContext), - `${componentName} must be wrapped with a ${providerName}` + return useStrictContext( + AriaLabellingContext, + `Must be wrapped with a <${providerName}>` ); } diff --git a/ts/axo/_internal/AxoBaseSegmentedControl.dom.tsx b/ts/axo/_internal/AxoBaseSegmentedControl.dom.tsx index ab745dbf02..421ce8d6a4 100644 --- a/ts/axo/_internal/AxoBaseSegmentedControl.dom.tsx +++ b/ts/axo/_internal/AxoBaseSegmentedControl.dom.tsx @@ -8,19 +8,13 @@ import type { HTMLAttributes, ReactNode, } from 'react'; -import React, { - createContext, - forwardRef, - memo, - useContext, - useId, - useMemo, -} from 'react'; +import React, { forwardRef, memo, useId, useMemo } from 'react'; import type { Transition } from 'framer-motion'; import { motion } from 'framer-motion'; import type { TailwindStyles } from '../tw.dom.js'; import { tw } from '../tw.dom.js'; import { ExperimentalAxoBadge } from '../AxoBadge.dom.js'; +import { createStrictContext, useStrictContext } from './StrictContext.dom.js'; const Namespace = 'AxoBaseSegmentedControl'; @@ -54,18 +48,7 @@ export namespace ExperimentalAxoBaseSegmentedControl { itemWidth: ItemWidth; }>; - const RootContext = createContext(null); - - // eslint-disable-next-line no-inner-declarations - function useRootContext(componentName: string): RootContextType { - const context = useContext(RootContext); - if (context == null) { - throw new Error( - `<${Namespace}.${componentName}> must be wrapped with <${Namespace}.Root>` - ); - } - return context; - } + const RootContext = createStrictContext(`${Namespace}.Root`); type VariantConfig = { rootStyles: TailwindStyles; @@ -81,7 +64,7 @@ export namespace ExperimentalAxoBaseSegmentedControl { ), indicatorStyles: tw( 'pointer-events-none absolute inset-0 z-10 rounded-full', - 'forced-colors:bg-[Highlight]' + 'forced-colors:bg-[SelectedItem]' ), }; @@ -167,7 +150,7 @@ export namespace ExperimentalAxoBaseSegmentedControl { forwardRef((props, ref: ForwardedRef) => { const { value, ...rest } = props; - const context = useRootContext('Item'); + const context = useStrictContext(RootContext); const config = Variants[context.variant]; const itemWidthStyles = ItemWidths[context.itemWidth]; @@ -189,12 +172,18 @@ export namespace ExperimentalAxoBaseSegmentedControl { type="button" {...rest} className={tw( - 'group relative flex min-w-0 items-center justify-center px-3 py-[5px]', + 'relative flex min-w-0 items-center justify-center px-3 py-[5px]', 'cursor-pointer rounded-full type-body-medium font-medium text-label-primary', 'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]', 'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]', + 'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]', itemWidthStyles, - isSelected && tw('forced-colors:text-[HighlightText]') + isSelected && tw('forced-colors:text-[SelectedItemText]'), + !isSelected && + tw( + 'data-[axo-contextmenu-state=open]:bg-fill-secondary', + 'forced-colors:data-[axo-contextmenu-state=open]:bg-[Highlight]' + ) )} > {props.children} diff --git a/ts/axo/_internal/StrictContext.dom.tsx b/ts/axo/_internal/StrictContext.dom.tsx new file mode 100644 index 0000000000..ed5c312796 --- /dev/null +++ b/ts/axo/_internal/StrictContext.dom.tsx @@ -0,0 +1,29 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Context } from 'react'; +import { createContext, useContext } from 'react'; + +const EMPTY: unique symbol = Symbol('STRICT_CONTEXT_EMPTY'); +const WRAPPER: unique symbol = Symbol('STRICT_CONTEXT_MESSAGE'); + +export type StrictContext = Context & { + [WRAPPER]: string; +}; + +export function createStrictContext(wrapper: string): StrictContext { + return Object.assign(createContext(EMPTY), { + [WRAPPER]: wrapper, + }); +} + +export function useStrictContext( + context: StrictContext, + message?: string +): T { + const value = useContext(context); + if (value === EMPTY) { + throw new Error(message ?? `Must be wrapped with <${context[WRAPPER]}>`); + } + return value; +}