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:hover .module-message__buttons,
.module-message__buttons:has([data-state='open']) { .module-message__buttons:has([data-axo-contextmenu-state='open']) {
opacity: 1; opacity: 1;
} }
@@ -5046,7 +5046,7 @@ button.module-calling-participants-list__contact {
} }
&:hover:not(:disabled, &--disabled, &--is-selected), &:hover:not(:disabled, &--disabled, &--is-selected),
&[data-state='open'] { &[data-axo-contextmenu-state='open'] {
background-color: light-dark( background-color: light-dark(
variables.$color-gray-05, variables.$color-gray-05,
variables.$color-gray-75 variables.$color-gray-75

View File

@@ -1099,7 +1099,7 @@ $secondary-text-color: light-dark(
.Preferences__ChatFolders__ChatSelection__Item--Clickable { .Preferences__ChatFolders__ChatSelection__Item--Clickable {
cursor: pointer; cursor: pointer;
&:hover .Preferences__ChatFolders__ChatSelection__ItemContent, &: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); background: light-dark(variables.$color-gray-02, variables.$color-gray-80);
} }
} }

View File

@@ -1,17 +1,14 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { import React, { memo, useCallback, useRef, useState } from 'react';
createContext,
memo,
useCallback,
useContext,
useRef,
useState,
} from 'react';
import type { ReactNode, MouseEvent, FC } from 'react'; import type { ReactNode, MouseEvent, FC } from 'react';
import { useLayoutEffect } from '@react-aria/utils'; import { useLayoutEffect } from '@react-aria/utils';
import { tw } from './tw.dom.js'; import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js'; import { assert } from './_internal/assert.dom.js';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
const Namespace = 'AriaClickable'; const Namespace = 'AriaClickable';
@@ -51,8 +48,8 @@ export namespace AriaClickable {
type TriggerStateUpdate = (state: TriggerState) => void; type TriggerStateUpdate = (state: TriggerState) => void;
const TriggerStateUpdateContext = createContext<TriggerStateUpdate | null>( const TriggerStateUpdateContext = createStrictContext<TriggerStateUpdate>(
null `${Namespace}.Root`
); );
/** /**
@@ -174,13 +171,7 @@ export namespace AriaClickable {
*/ */
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => { export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
const ref = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useContext(TriggerStateUpdateContext); const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext);
if (onTriggerStateUpdate == null) {
throw new Error(
`<${Namespace}.HiddenTrigger> must be wrapped with <${Namespace}.Root>`
);
}
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate); const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => { useLayoutEffect(() => {

View File

@@ -31,6 +31,7 @@ export namespace ExperimentalAxoBadge {
'flex size-fit items-center justify-center-safe overflow-clip', 'flex size-fit items-center justify-center-safe overflow-clip',
'rounded-full font-semibold', 'rounded-full font-semibold',
'bg-color-fill-primary text-label-primary-on-color', 'bg-color-fill-primary text-label-primary-on-color',
'forced-color-adjust-none forced-colors:bg-[Mark] forced-colors:text-[MarkText]',
'select-none' 'select-none'
); );

View File

@@ -1,6 +1,13 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { ContextMenu } from 'radix-ui';
import type { import type {
FC, FC,
@@ -12,6 +19,10 @@ import { AxoSymbol } from './AxoSymbol.dom.js';
import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.dom.js';
import { tw } from './tw.dom.js'; import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js'; import { assert } from './_internal/assert.dom.js';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
const Namespace = 'AxoContextMenu'; const Namespace = 'AxoContextMenu';
@@ -55,6 +66,14 @@ const Namespace = 'AxoContextMenu';
* ``` * ```
*/ */
export namespace AxoContextMenu { export namespace AxoContextMenu {
export type RootContextType = Readonly<{
open: boolean;
}>;
export const RootContext = createStrictContext<RootContextType>(
`${Namespace}.RootContext`
);
/** /**
* Component: <AxoContextMenu.Root> * Component: <AxoContextMenu.Root>
* -------------------------------- * --------------------------------
@@ -63,10 +82,27 @@ export namespace AxoContextMenu {
export type RootProps = AxoBaseMenu.MenuRootProps; export type RootProps = AxoBaseMenu.MenuRootProps;
export const Root: FC<RootProps> = memo(props => { 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 ( return (
<ContextMenu.Root onOpenChange={props.onOpenChange}> <RootContext.Provider value={context}>
{props.children} <ContextMenu.Root onOpenChange={handleOpenChange}>
</ContextMenu.Root> {props.children}
</ContextMenu.Root>
</RootContext.Provider>
); );
}); });
@@ -125,6 +161,7 @@ export namespace AxoContextMenu {
export type TriggerProps = AxoBaseMenu.MenuTriggerProps; export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
export const Trigger: FC<TriggerProps> = memo(props => { export const Trigger: FC<TriggerProps> = memo(props => {
const context = useStrictContext(RootContext);
const [disableCurrentEvent, setDisableCurrentEvent] = useState(false); const [disableCurrentEvent, setDisableCurrentEvent] = useState(false);
const handleContextMenuCapture = useCallback( const handleContextMenuCapture = useCallback(
@@ -164,7 +201,8 @@ export namespace AxoContextMenu {
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={disableCurrentEvent || props.disabled} disabled={disableCurrentEvent || props.disabled}
data-axo-context-menu-trigger data-axo-contextmenu-trigger
data-axo-contextmenu-state={context.open ? 'open' : 'closed'}
> >
{props.children} {props.children}
</ContextMenu.Trigger> </ContextMenu.Trigger>
@@ -176,7 +214,7 @@ export namespace AxoContextMenu {
export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler { export function useAxoContextMenuOutsideKeyboardTrigger(): KeyboardEventHandler {
return useContextMenuTriggerKeyboardEventHandler(event => { return useContextMenuTriggerKeyboardEventHandler(event => {
return assert( 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?` `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 descriptionId = useId();
const { labelRef, descriptionRef } = useAriaLabellingContext( const { labelRef, descriptionRef } = useAriaLabellingContext(
`<${Namespace}.Header>`, `${Namespace}.Content/SubContent`
`<${Namespace}.Content/SubContent>`
); );
return ( return (

View File

@@ -2,24 +2,22 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { FC } from 'react'; 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 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 { AxoDropdownMenu } from './AxoDropdownMenu.dom.js';
import { AxoContextMenu } from './AxoContextMenu.dom.js'; import { AxoContextMenu } from './AxoContextMenu.dom.js';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
const Namespace = 'AxoMenuBuilder'; const Namespace = 'AxoMenuBuilder';
export namespace AxoMenuBuilder { export namespace AxoMenuBuilder {
export type Renderer = 'AxoDropdownMenu' | 'AxoContextMenu'; export type Renderer = 'AxoDropdownMenu' | 'AxoContextMenu';
const MenuBuilderContext = createContext<Renderer | null>(null); const MenuBuilderContext = createStrictContext<Renderer>(`${Namespace}.Root`);
// eslint-disable-next-line no-inner-declarations
function useMenuBuilderContext(): Renderer {
const context = useContext(MenuBuilderContext);
return assert(context, `Must be wrapped with <${Namespace}.Root>`);
}
export type RootProps = AxoBaseMenu.MenuRootProps & export type RootProps = AxoBaseMenu.MenuRootProps &
Readonly<{ Readonly<{
@@ -48,7 +46,7 @@ export namespace AxoMenuBuilder {
Root.displayName = `${Namespace}.Root`; Root.displayName = `${Namespace}.Root`;
export const Trigger: FC<AxoBaseMenu.MenuTriggerProps> = memo(props => { export const Trigger: FC<AxoBaseMenu.MenuTriggerProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Trigger {...props} />; return <AxoDropdownMenu.Trigger {...props} />;
} }
@@ -61,7 +59,7 @@ export namespace AxoMenuBuilder {
Trigger.displayName = `${Namespace}.Trigger`; Trigger.displayName = `${Namespace}.Trigger`;
export const Content: FC<AxoBaseMenu.MenuContentProps> = memo(props => { export const Content: FC<AxoBaseMenu.MenuContentProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Content {...props} />; return <AxoDropdownMenu.Content {...props} />;
} }
@@ -74,7 +72,7 @@ export namespace AxoMenuBuilder {
Content.displayName = `${Namespace}.Content`; Content.displayName = `${Namespace}.Content`;
export const Item: FC<AxoBaseMenu.MenuItemProps> = memo(props => { export const Item: FC<AxoBaseMenu.MenuItemProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Item {...props} />; return <AxoDropdownMenu.Item {...props} />;
} }
@@ -87,7 +85,7 @@ export namespace AxoMenuBuilder {
Item.displayName = `${Namespace}.Item`; Item.displayName = `${Namespace}.Item`;
export const Group: FC<AxoBaseMenu.MenuGroupProps> = memo(props => { export const Group: FC<AxoBaseMenu.MenuGroupProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Group {...props} />; return <AxoDropdownMenu.Group {...props} />;
} }
@@ -100,7 +98,7 @@ export namespace AxoMenuBuilder {
Group.displayName = `${Namespace}.Group`; Group.displayName = `${Namespace}.Group`;
export const Label: FC<AxoBaseMenu.MenuLabelProps> = memo(props => { export const Label: FC<AxoBaseMenu.MenuLabelProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Label {...props} />; return <AxoDropdownMenu.Label {...props} />;
} }
@@ -113,7 +111,7 @@ export namespace AxoMenuBuilder {
Label.displayName = `${Namespace}.Label`; Label.displayName = `${Namespace}.Label`;
export const Separator: FC<AxoBaseMenu.MenuSeparatorProps> = memo(props => { export const Separator: FC<AxoBaseMenu.MenuSeparatorProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Separator {...props} />; return <AxoDropdownMenu.Separator {...props} />;
} }
@@ -127,7 +125,7 @@ export namespace AxoMenuBuilder {
export const CheckboxItem: FC<AxoBaseMenu.MenuCheckboxItemProps> = memo( export const CheckboxItem: FC<AxoBaseMenu.MenuCheckboxItemProps> = memo(
props => { props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.CheckboxItem {...props} />; return <AxoDropdownMenu.CheckboxItem {...props} />;
} }
@@ -141,7 +139,7 @@ export namespace AxoMenuBuilder {
CheckboxItem.displayName = `${Namespace}.CheckboxItem`; CheckboxItem.displayName = `${Namespace}.CheckboxItem`;
export const RadioGroup: FC<AxoBaseMenu.MenuRadioGroupProps> = memo(props => { export const RadioGroup: FC<AxoBaseMenu.MenuRadioGroupProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioGroup {...props} />; return <AxoDropdownMenu.RadioGroup {...props} />;
} }
@@ -154,7 +152,7 @@ export namespace AxoMenuBuilder {
RadioGroup.displayName = `${Namespace}.RadioGroup`; RadioGroup.displayName = `${Namespace}.RadioGroup`;
export const RadioItem: FC<AxoBaseMenu.MenuRadioItemProps> = memo(props => { export const RadioItem: FC<AxoBaseMenu.MenuRadioItemProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.RadioItem {...props} />; return <AxoDropdownMenu.RadioItem {...props} />;
} }
@@ -167,7 +165,7 @@ export namespace AxoMenuBuilder {
RadioItem.displayName = `${Namespace}.RadioItem`; RadioItem.displayName = `${Namespace}.RadioItem`;
export const Sub: FC<AxoBaseMenu.MenuSubProps> = memo(props => { export const Sub: FC<AxoBaseMenu.MenuSubProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.Sub {...props} />; return <AxoDropdownMenu.Sub {...props} />;
} }
@@ -180,7 +178,7 @@ export namespace AxoMenuBuilder {
Sub.displayName = `${Namespace}.Sub`; Sub.displayName = `${Namespace}.Sub`;
export const SubTrigger: FC<AxoBaseMenu.MenuSubTriggerProps> = memo(props => { export const SubTrigger: FC<AxoBaseMenu.MenuSubTriggerProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubTrigger {...props} />; return <AxoDropdownMenu.SubTrigger {...props} />;
} }
@@ -193,7 +191,7 @@ export namespace AxoMenuBuilder {
SubTrigger.displayName = `${Namespace}.SubTrigger`; SubTrigger.displayName = `${Namespace}.SubTrigger`;
export const SubContent: FC<AxoBaseMenu.MenuSubContentProps> = memo(props => { export const SubContent: FC<AxoBaseMenu.MenuSubContentProps> = memo(props => {
const renderer = useMenuBuilderContext(); const renderer = useStrictContext(MenuBuilderContext);
if (renderer === 'AxoDropdownMenu') { if (renderer === 'AxoDropdownMenu') {
return <AxoDropdownMenu.SubContent {...props} />; return <AxoDropdownMenu.SubContent {...props} />;
} }

View File

@@ -1,10 +1,13 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { CSSProperties, FC, ReactNode } from 'react';
import type { TailwindStyles } from './tw.dom.js'; import type { TailwindStyles } from './tw.dom.js';
import { tw } 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'; const Namespace = 'AxoScrollArea';
@@ -15,13 +18,10 @@ const AXO_SCROLL_AREA_TIMELINE_HORIZONTAL =
type AxoScrollAreaOrientation = 'vertical' | 'horizontal' | 'both'; type AxoScrollAreaOrientation = 'vertical' | 'horizontal' | 'both';
const AxoScrollAreaOrientationContext = const AxoScrollAreaOrientationContext =
createContext<AxoScrollAreaOrientation | null>(null); createStrictContext<AxoScrollAreaOrientation>(`${Namespace}.Root`);
export function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation { export function useAxoScrollAreaOrientation(): AxoScrollArea.Orientation {
return assert( return useStrictContext(AxoScrollAreaOrientationContext);
useContext(AxoScrollAreaOrientationContext),
`Must be wrapped with <${Namespace}.Root>`
);
} }
/** /**
@@ -74,15 +74,9 @@ export namespace AxoScrollArea {
scrollBehavior: ScrollBehavior; scrollBehavior: ScrollBehavior;
}>; }>;
const ScrollAreaConfigContext = createContext<ScrollAreaConfig | null>(null); const ScrollAreaConfigContext = createStrictContext<ScrollAreaConfig>(
`${Namespace}.Root`
// eslint-disable-next-line no-inner-declarations );
function useAxoScrollAreaConfig(): ScrollAreaConfig {
return assert(
useContext(ScrollAreaConfigContext),
`Must be wrapped with <${Namespace}.Root>`
);
}
/** /**
* Component: <AxoScrollArea.Root> * Component: <AxoScrollArea.Root>
@@ -221,7 +215,7 @@ export namespace AxoScrollArea {
scrollbarGutter, scrollbarGutter,
scrollbarVisibility, scrollbarVisibility,
scrollBehavior, scrollBehavior,
} = useAxoScrollAreaConfig(); } = useStrictContext(ScrollAreaConfigContext);
const style = useMemo((): CSSProperties => { const style = useMemo((): CSSProperties => {
const hasVerticalScrollbar = orientation !== 'horizontal'; const hasVerticalScrollbar = orientation !== 'horizontal';
@@ -376,7 +370,7 @@ export namespace AxoScrollArea {
export const Hint: FC<HintProps> = memo(props => { export const Hint: FC<HintProps> = memo(props => {
const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props; const { edge, animationStartOffset = 1, animationEndOffset = 20 } = props;
const orientation = useAxoScrollAreaOrientation(); const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useAxoScrollAreaConfig(); const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
const style = useMemo((): CSSProperties => { const style = useMemo((): CSSProperties => {
const isVerticalEdge = edge === 'top' || edge === 'bottom'; const isVerticalEdge = edge === 'top' || edge === 'bottom';
@@ -445,7 +439,7 @@ export namespace AxoScrollArea {
} = props; } = props;
const orientation = useAxoScrollAreaOrientation(); const orientation = useAxoScrollAreaOrientation();
const { scrollbarWidth } = useAxoScrollAreaConfig(); const { scrollbarWidth } = useStrictContext(ScrollAreaConfigContext);
const style = useMemo(() => { const style = useMemo(() => {
const hasVerticalScrollbar = orientation !== 'horizontal'; const hasVerticalScrollbar = orientation !== 'horizontal';

View File

@@ -2,16 +2,16 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { RefCallback } from 'react'; import type { RefCallback } from 'react';
import { createContext, useContext, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { assert } from './assert.dom.js'; import { createStrictContext, useStrictContext } from './StrictContext.dom.js';
type AriaLabellingContextType = Readonly<{ type AriaLabellingContextType = Readonly<{
labelRef: RefCallback<HTMLElement>; labelRef: RefCallback<HTMLElement>;
descriptionRef: RefCallback<HTMLElement>; descriptionRef: RefCallback<HTMLElement>;
}>; }>;
const AriaLabellingContext = createContext<AriaLabellingContextType | null>( const AriaLabellingContext = createStrictContext<AriaLabellingContextType>(
null 'AriaLabellingContext.Provider'
); );
export type CreateAriaLabellingContextResult = Readonly<{ export type CreateAriaLabellingContextResult = Readonly<{
@@ -42,11 +42,10 @@ export function useCreateAriaLabellingContext(): CreateAriaLabellingContextResul
export const AriaLabellingProvider = AriaLabellingContext.Provider; export const AriaLabellingProvider = AriaLabellingContext.Provider;
export function useAriaLabellingContext( export function useAriaLabellingContext(
componentName: string,
providerName: string providerName: string
): AriaLabellingContextType { ): AriaLabellingContextType {
return assert( return useStrictContext(
useContext(AriaLabellingContext), AriaLabellingContext,
`${componentName} must be wrapped with a ${providerName}` `Must be wrapped with a <${providerName}>`
); );
} }

View File

@@ -8,19 +8,13 @@ import type {
HTMLAttributes, HTMLAttributes,
ReactNode, ReactNode,
} from 'react'; } from 'react';
import React, { import React, { forwardRef, memo, useId, useMemo } from 'react';
createContext,
forwardRef,
memo,
useContext,
useId,
useMemo,
} from 'react';
import type { Transition } from 'framer-motion'; import type { Transition } from 'framer-motion';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { TailwindStyles } from '../tw.dom.js'; import type { TailwindStyles } from '../tw.dom.js';
import { tw } from '../tw.dom.js'; import { tw } from '../tw.dom.js';
import { ExperimentalAxoBadge } from '../AxoBadge.dom.js'; import { ExperimentalAxoBadge } from '../AxoBadge.dom.js';
import { createStrictContext, useStrictContext } from './StrictContext.dom.js';
const Namespace = 'AxoBaseSegmentedControl'; const Namespace = 'AxoBaseSegmentedControl';
@@ -54,18 +48,7 @@ export namespace ExperimentalAxoBaseSegmentedControl {
itemWidth: ItemWidth; itemWidth: ItemWidth;
}>; }>;
const RootContext = createContext<RootContextType | null>(null); const RootContext = createStrictContext<RootContextType>(`${Namespace}.Root`);
// 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;
}
type VariantConfig = { type VariantConfig = {
rootStyles: TailwindStyles; rootStyles: TailwindStyles;
@@ -81,7 +64,7 @@ export namespace ExperimentalAxoBaseSegmentedControl {
), ),
indicatorStyles: tw( indicatorStyles: tw(
'pointer-events-none absolute inset-0 z-10 rounded-full', '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>) => { forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
const { value, ...rest } = props; const { value, ...rest } = props;
const context = useRootContext('Item'); const context = useStrictContext(RootContext);
const config = Variants[context.variant]; const config = Variants[context.variant];
const itemWidthStyles = ItemWidths[context.itemWidth]; const itemWidthStyles = ItemWidths[context.itemWidth];
@@ -189,12 +172,18 @@ export namespace ExperimentalAxoBaseSegmentedControl {
type="button" type="button"
{...rest} {...rest}
className={tw( 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', '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]', '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:bg-[ButtonFace] forced-colors:text-[ButtonText]',
'forced-colors:data-[axo-contextmenu-state=open]:text-[HighlightText]',
itemWidthStyles, 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} {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;
}