Highlight chat folder with open context menu

This commit is contained in:
Jamie
2025-11-12 19:51:15 -08:00
committed by GitHub
parent b29aedf1c8
commit 8e79bb5050
11 changed files with 138 additions and 100 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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<TriggerStateUpdate | null>(
null
const TriggerStateUpdateContext = createStrictContext<TriggerStateUpdate>(
`${Namespace}.Root`
);
/**
@@ -174,13 +171,7 @@ export namespace AriaClickable {
*/
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
const ref = useRef<HTMLButtonElement>(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(() => {

View File

@@ -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'
);

View File

@@ -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<RootContextType>(
`${Namespace}.RootContext`
);
/**
* Component: <AxoContextMenu.Root>
* --------------------------------
@@ -63,10 +82,27 @@ export namespace AxoContextMenu {
export type RootProps = AxoBaseMenu.MenuRootProps;
export const Root: FC<RootProps> = 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 (
<ContextMenu.Root onOpenChange={props.onOpenChange}>
{props.children}
</ContextMenu.Root>
<RootContext.Provider value={context}>
<ContextMenu.Root onOpenChange={handleOpenChange}>
{props.children}
</ContextMenu.Root>
</RootContext.Provider>
);
});
@@ -125,6 +161,7 @@ export namespace AxoContextMenu {
export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = 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}
</ContextMenu.Trigger>
@@ -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?`
);
});

View File

@@ -303,8 +303,7 @@ export namespace AxoDropdownMenu {
const descriptionId = useId();
const { labelRef, descriptionRef } = useAriaLabellingContext(
`<${Namespace}.Header>`,
`<${Namespace}.Content/SubContent>`
`${Namespace}.Content/SubContent`
);
return (

View File

@@ -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<Renderer | null>(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<Renderer>(`${Namespace}.Root`);
export type RootProps = AxoBaseMenu.MenuRootProps &
Readonly<{
@@ -48,7 +46,7 @@ export namespace AxoMenuBuilder {
Root.displayName = `${Namespace}.Root`;
export const Trigger: FC<AxoBaseMenu.MenuTriggerProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Trigger {...props} />;
}
@@ -61,7 +59,7 @@ export namespace AxoMenuBuilder {
Trigger.displayName = `${Namespace}.Trigger`;
export const Content: FC<AxoBaseMenu.MenuContentProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Content {...props} />;
}
@@ -74,7 +72,7 @@ export namespace AxoMenuBuilder {
Content.displayName = `${Namespace}.Content`;
export const Item: FC<AxoBaseMenu.MenuItemProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Item {...props} />;
}
@@ -87,7 +85,7 @@ export namespace AxoMenuBuilder {
Item.displayName = `${Namespace}.Item`;
export const Group: FC<AxoBaseMenu.MenuGroupProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Group {...props} />;
}
@@ -100,7 +98,7 @@ export namespace AxoMenuBuilder {
Group.displayName = `${Namespace}.Group`;
export const Label: FC<AxoBaseMenu.MenuLabelProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Label {...props} />;
}
@@ -113,7 +111,7 @@ export namespace AxoMenuBuilder {
Label.displayName = `${Namespace}.Label`;
export const Separator: FC<AxoBaseMenu.MenuSeparatorProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Separator {...props} />;
}
@@ -127,7 +125,7 @@ export namespace AxoMenuBuilder {
export const CheckboxItem: FC<AxoBaseMenu.MenuCheckboxItemProps> = memo(
props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.CheckboxItem {...props} />;
}
@@ -141,7 +139,7 @@ export namespace AxoMenuBuilder {
CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
export const RadioGroup: FC<AxoBaseMenu.MenuRadioGroupProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioGroup {...props} />;
}
@@ -154,7 +152,7 @@ export namespace AxoMenuBuilder {
RadioGroup.displayName = `${Namespace}.RadioGroup`;
export const RadioItem: FC<AxoBaseMenu.MenuRadioItemProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioItem {...props} />;
}
@@ -167,7 +165,7 @@ export namespace AxoMenuBuilder {
RadioItem.displayName = `${Namespace}.RadioItem`;
export const Sub: FC<AxoBaseMenu.MenuSubProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Sub {...props} />;
}
@@ -180,7 +178,7 @@ export namespace AxoMenuBuilder {
Sub.displayName = `${Namespace}.Sub`;
export const SubTrigger: FC<AxoBaseMenu.MenuSubTriggerProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubTrigger {...props} />;
}
@@ -193,7 +191,7 @@ export namespace AxoMenuBuilder {
SubTrigger.displayName = `${Namespace}.SubTrigger`;
export const SubContent: FC<AxoBaseMenu.MenuSubContentProps> = memo(props => {
const renderer = useMenuBuilderContext();
const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubContent {...props} />;
}

View File

@@ -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<AxoScrollAreaOrientation | null>(null);
createStrictContext<AxoScrollAreaOrientation>(`${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<ScrollAreaConfig | null>(null);
// eslint-disable-next-line no-inner-declarations
function useAxoScrollAreaConfig(): ScrollAreaConfig {
return assert(
useContext(ScrollAreaConfigContext),
`Must be wrapped with <${Namespace}.Root>`
);
}
const ScrollAreaConfigContext = createStrictContext<ScrollAreaConfig>(
`${Namespace}.Root`
);
/**
* Component: <AxoScrollArea.Root>
@@ -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<HintProps> = 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';

View File

@@ -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<HTMLElement>;
descriptionRef: RefCallback<HTMLElement>;
}>;
const AriaLabellingContext = createContext<AriaLabellingContextType | null>(
null
const AriaLabellingContext = createStrictContext<AriaLabellingContextType>(
'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}>`
);
}

View File

@@ -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<RootContextType | null>(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<RootContextType>(`${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<HTMLButtonElement>) => {
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}

View File

@@ -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<T> = Context<T | typeof EMPTY> & {
[WRAPPER]: string;
};
export function createStrictContext<T>(wrapper: string): StrictContext<T> {
return Object.assign(createContext<T | typeof EMPTY>(EMPTY), {
[WRAPPER]: wrapper,
});
}
export function useStrictContext<T>(
context: StrictContext<T>,
message?: string
): T {
const value = useContext(context);
if (value === EMPTY) {
throw new Error(message ?? `Must be wrapped with <${context[WRAPPER]}>`);
}
return value;
}