Init PinnedMessagesBar UI

This commit is contained in:
Jamie
2025-11-19 10:55:47 -08:00
committed by GitHub
parent 10a9e40a2b
commit 710a54d43f
7 changed files with 469 additions and 12 deletions

View File

@@ -1650,6 +1650,34 @@
"messageformat": "Pin",
"description": "Message > Context Menu > Pin Message > Dialog > Pin Button"
},
"icu:PinnedMessagesBar__AccessibilityLabel": {
"messageformat": "{pinsCount, plural, one {Pinned message} other {Pinned messages}}",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Accessibility label"
},
"icu:PinnedMessagesBar__Tab__AccessibilityLabel": {
"messageformat": "Go to pin {pinNumber, number}",
"description": "Conversation > With *multiple* pinned messages > Pinned messages bar > Vertical tabs > Tab item > Accessibility Label"
},
"icu:PinnedMessagesBar__GoToMessageClickableArea__AccessibilityLabel": {
"messageformat": "Go to message",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Clickable area (Goes to pinned message) > Accessibility label"
},
"icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel": {
"messageformat": "More actions",
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu button > Accessibility label"
},
"icu:PinnedMessagesBar__ActionsMenu__UnpinMessage": {
"messageformat": "Unpin message",
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu > Unpin message"
},
"icu:PinnedMessagesBar__ActionsMenu__GoToMessage": {
"messageformat": "Go to message",
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu > Go to message"
},
"icu:PinnedMessagesBar__ActionsMenu__SeeAllMessages": {
"messageformat": "See all messages",
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu > See all messages"
},
"icu:sessionEnded": {
"messageformat": "Secure session reset",
"description": "This is a past tense, informational message. In other words, your secure session has been reset."

View File

@@ -1,8 +1,9 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { ReactNode, MouseEvent, FC } from 'react';
import { useLayoutEffect } from '@react-aria/utils';
import { computeAccessibleName } from 'dom-accessibility-api';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.dom.js';
import {
@@ -146,6 +147,10 @@ export namespace AriaClickable {
*/
export type HiddenTriggerProps = Readonly<{
/**
* Describe the action to be taken `onClick`
*/
'aria-label'?: string;
/**
* This should reference the ID of an element that describes the action that
* will be taken `onClick`, not the entire clickable root.
@@ -156,10 +161,12 @@ export namespace AriaClickable {
* <HiddenTrigger aria-labelledby="see-more-1"/>
* ```
*/
'aria-labelledby': string;
'aria-labelledby'?: string;
onClick: (event: MouseEvent) => void;
}>;
const hiddenTriggerDisplayName = `${Namespace}.HiddenTrigger`;
/**
* Provides an invisible button that fills the entire area of
* `<AriaClickable.Root>`
@@ -221,16 +228,26 @@ export namespace AriaClickable {
};
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
assert(
computeAccessibleName(assert(ref.current)) !== '',
`${hiddenTriggerDisplayName} child must have an accessible name`
);
}
});
return (
<button
ref={ref}
type="button"
className={tw('absolute inset-0 z-10 outline-0')}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
onClick={props.onClick}
/>
);
});
HiddenTrigger.displayName = `${Namespace}.HiddenTrigger`;
HiddenTrigger.displayName = hiddenTriggerDisplayName;
}

View File

@@ -66,11 +66,11 @@ const Namespace = 'AxoContextMenu';
* ```
*/
export namespace AxoContextMenu {
export type RootContextType = Readonly<{
type RootContextType = Readonly<{
open: boolean;
}>;
export const RootContext = createStrictContext<RootContextType>(
const RootContext = createStrictContext<RootContextType>(
`${Namespace}.RootContext`
);

View File

@@ -1,6 +1,14 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useEffect, useId, useRef } from 'react';
import React, {
memo,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { DropdownMenu } from 'radix-ui';
import type { FC, ReactNode } from 'react';
import { computeAccessibleName } from 'dom-accessibility-api';
@@ -17,6 +25,10 @@ import {
getElementAriaRole,
isAriaWidgetRole,
} from './_internal/ariaRoles.dom.js';
import {
createStrictContext,
useStrictContext,
} from './_internal/StrictContext.dom.js';
const Namespace = 'AxoDropdownMenu';
@@ -63,6 +75,14 @@ const Namespace = 'AxoDropdownMenu';
* ```
*/
export namespace AxoDropdownMenu {
type RootContextType = Readonly<{
open: boolean;
}>;
const RootContext = createStrictContext<RootContextType>(
`${Namespace}.RootContext`
);
/**
* Component: <AxoDropdownMenu.Root>
* ---------------------------------
@@ -71,17 +91,37 @@ export namespace AxoDropdownMenu {
export type RootProps = AxoBaseMenu.MenuRootProps &
Readonly<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>;
/**
* Contains all the parts of a dropdown menu.
*/
export const Root: FC<RootProps> = memo(props => {
const { onOpenChange } = props;
const [open, setOpen] = useState(false);
if (typeof props.open === 'boolean' && open !== props.open) {
setOpen(props.open);
}
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange]
);
const context = useMemo((): RootContextType => {
return { open };
}, [open]);
return (
<DropdownMenu.Root open={props.open} onOpenChange={props.onOpenChange}>
{props.children}
</DropdownMenu.Root>
<RootContext.Provider value={context}>
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
{props.children}
</DropdownMenu.Root>
</RootContext.Provider>
);
});
@@ -102,6 +142,7 @@ export namespace AxoDropdownMenu {
* against the trigger.
*/
export const Trigger: FC<TriggerProps> = memo(props => {
const context = useStrictContext(RootContext);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -122,7 +163,13 @@ export namespace AxoDropdownMenu {
});
return (
<DropdownMenu.Trigger ref={ref} asChild disabled={props.disabled}>
<DropdownMenu.Trigger
ref={ref}
asChild
disabled={props.disabled}
data-axo-dropdownmenu-trigger
data-axo-dropdownmenu-state={context.open ? 'open' : 'closed'}
>
{props.children}
</DropdownMenu.Trigger>
);

View File

@@ -36,31 +36,38 @@ export namespace AxoIconButton {
const Variants: Record<Variant, TailwindStyles> = {
secondary: tw(
'bg-fill-secondary pressed:bg-fill-secondary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
'text-label-primary disabled:text-label-disabled',
pressedStyles.fillInverted
),
primary: tw(
'bg-color-fill-primary pressed:bg-color-fill-primary-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-primary-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
),
affirmative: tw(
'bg-color-fill-affirmative pressed:bg-color-fill-affirmative-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-affirmative-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
),
destructive: tw(
'bg-color-fill-destructive pressed:bg-color-fill-destructive-pressed',
'data-[axo-dropdownmenu-state=open]:bg-color-fill-destructive-pressed',
'text-label-primary-on-color disabled:text-label-disabled-on-color',
pressedStyles.fillInverted
),
'borderless-secondary': tw(
'hovered:bg-fill-secondary pressed:bg-fill-secondary-pressed',
'focus:bg-fill-secondary',
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
'text-label-primary disabled:text-label-disabled',
pressedStyles.colorFillPrimary
),
'floating-secondary': tw(
'bg-fill-floating pressed:bg-fill-floating-pressed',
'data-[axo-dropdownmenu-state=open]:bg-fill-floating-pressed',
'text-label-primary disabled:text-label-disabled',
'shadow-elevation-1',
pressedStyles.fillInverted
@@ -130,7 +137,7 @@ export namespace AxoIconButton {
>
<span
className={tw(
'forced-color-adjust-none',
'align-top leading-none forced-color-adjust-none',
experimentalSpinner != null ? 'opacity-0' : null
)}
>

View File

@@ -0,0 +1,81 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { Pin, PinId } from './PinnedMessagesBar.dom.js';
import { PinnedMessagesBar } from './PinnedMessagesBar.dom.js';
import { tw } from '../../../axo/tw.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/PinnedMessages/PinnedMessagesBar',
} satisfies Meta;
const PIN_1: Pin = {
id: 'pin-1' as PinId,
sender: {
id: 'conversation-1',
title: 'Jamie',
isMe: true,
},
message: {
id: 'message-1',
body: 'What should we get for lunch?',
},
};
const PIN_2: Pin = {
id: 'pin-2' as PinId,
sender: {
id: 'conversation-1',
title: 'Tyler',
isMe: false,
},
message: {
id: 'message-2',
body: 'We found a cute pottery store close to Inokashira Park that were going to check out on Saturday. Anyone want to meet at the south exit at Kichijoji station at 1pm? Too early?',
},
};
const PIN_3: Pin = {
id: 'pin-3' as PinId,
sender: {
id: 'conversation-1',
title: 'Adrian',
isMe: false,
},
message: {
id: 'message-3',
body: 'Photo',
attachment: {
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
},
};
function Template(props: { defaultCurrent: PinId; pins: ReadonlyArray<Pin> }) {
const [current, setCurrent] = useState(props.defaultCurrent);
return (
<PinnedMessagesBar
i18n={i18n}
current={current}
onCurrentChange={setCurrent}
pins={props.pins}
onPinGoTo={action('onPinGoTo')}
onPinRemove={action('onPinRemove')}
onPinsShowAll={action('onPinsShowAll')}
/>
);
}
export function Default(): JSX.Element {
return (
<div className={tw('flex max-w-4xl flex-col gap-4 bg-fill-inverted p-4')}>
<Template defaultCurrent={PIN_1.id} pins={[PIN_1]} />
<Template defaultCurrent={PIN_2.id} pins={[PIN_1, PIN_2]} />
<Template defaultCurrent={PIN_3.id} pins={[PIN_1, PIN_2, PIN_3]} />
</div>
);
}

View File

@@ -0,0 +1,277 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { memo, useCallback } from 'react';
import { Tabs } from 'radix-ui';
import type { LocalizerType } from '../../../types/I18N.std.js';
import { tw } from '../../../axo/tw.dom.js';
import { strictAssert } from '../../../util/assert.std.js';
import { AxoIconButton } from '../../../axo/AxoIconButton.dom.js';
import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.js';
import { AriaClickable } from '../../../axo/AriaClickable.dom.js';
import { UserText } from '../../UserText.dom.js';
export type PinId = string & { PinId: never };
export type Pin = Readonly<{
id: PinId;
sender: {
id: string;
title: string;
isMe: boolean;
};
message: {
id: string;
body: string;
attachment?: {
url: string;
};
};
}>;
export type PinnedMessagesBarProps = Readonly<{
i18n: LocalizerType;
pins: ReadonlyArray<Pin>;
current: PinId;
onCurrentChange: (current: PinId) => void;
onPinGoTo: (pinId: PinId) => void;
onPinRemove: (pinId: PinId) => void;
onPinsShowAll: () => void;
}>;
export const PinnedMessagesBar = memo(function PinnedMessagesBar(
props: PinnedMessagesBarProps
) {
const { i18n, onCurrentChange } = props;
strictAssert(props.pins.length > 0, 'Must have at least one pin');
const handleValueChange = useCallback(
(value: string) => {
onCurrentChange(value as PinId);
},
[onCurrentChange]
);
if (props.pins.length === 1) {
const pin = props.pins.at(0);
strictAssert(pin != null, 'Missing pin');
return (
<Container i18n={i18n} pinsCount={props.pins.length}>
<Content
i18n={i18n}
pin={pin}
onPinGoTo={props.onPinGoTo}
onPinRemove={props.onPinRemove}
onPinsShowAll={props.onPinsShowAll}
/>
</Container>
);
}
return (
<Tabs.Root
orientation="vertical"
value={props.current}
onValueChange={handleValueChange}
asChild
activationMode="manual"
>
<Container i18n={i18n} pinsCount={props.pins.length}>
<TabsList
i18n={i18n}
pins={props.pins}
current={props.current}
onCurrentChange={props.onCurrentChange}
/>
{props.pins.map(pin => {
return (
<Tabs.Content key={pin.id} tabIndex={-1} value={pin.id} asChild>
<Content
i18n={i18n}
pin={pin}
onPinGoTo={props.onPinGoTo}
onPinRemove={props.onPinRemove}
onPinsShowAll={props.onPinsShowAll}
/>
</Tabs.Content>
);
})}
</Container>
</Tabs.Root>
);
});
function Container(props: {
i18n: LocalizerType;
pinsCount: number;
children: ReactNode;
}) {
const { i18n } = props;
return (
<section
aria-label={i18n('icu:PinnedMessagesBar__AccessibilityLabel', {
pinsCount: props.pinsCount,
})}
>
<AriaClickable.Root
className={tw(
'flex h-14 items-center bg-background-primary py-2.5 pe-3 select-none',
'rounded-xs',
'outline-0 outline-border-focused',
'data-[focused]:outline-[2.5px]',
props.pinsCount === 1 && 'ps-4'
)}
>
{props.children}
</AriaClickable.Root>
</section>
);
}
function TabsList(props: {
i18n: LocalizerType;
pins: ReadonlyArray<Pin>;
current: PinId;
onCurrentChange: (current: PinId) => void;
}) {
const { i18n } = props;
strictAssert(props.pins.length >= 2, 'Too few pins for tabs');
strictAssert(props.pins.length <= 3, 'Too many pins for tabs');
return (
<AriaClickable.SubWidget>
<Tabs.List className={tw('flex h-full flex-col')}>
{props.pins.toReversed().map((pin, pinIndex) => {
return (
<TabTrigger
key={pin.id}
i18n={i18n}
pin={pin}
pinNumber={pinIndex + 1}
pinsCount={props.pins.length}
/>
);
})}
</Tabs.List>
</AriaClickable.SubWidget>
);
}
function TabTrigger(props: {
i18n: LocalizerType;
pin: Pin;
pinNumber: number;
pinsCount: number;
}) {
const { i18n } = props;
return (
<Tabs.Trigger
value={props.pin.id}
aria-label={i18n('icu:PinnedMessagesBar__Tab__AccessibilityLabel', {
pinNumber: props.pinNumber,
})}
className={tw(
'group flex-1 px-[7px] outline-0',
props.pinsCount === 3 ? 'py-[1px]' : 'py-0.5'
)}
>
<span
className={tw(
'block h-full w-0.5 rounded-full',
'bg-label-disabled',
'group-data-[state=active]:bg-label-primary',
'outline-border-focused',
'group-focused:outline-[2.5px]'
)}
/>
</Tabs.Trigger>
);
}
function Content(props: {
i18n: LocalizerType;
pin: Pin;
onPinGoTo: (pinId: PinId) => void;
onPinRemove: (pinId: PinId) => void;
onPinsShowAll: () => void;
}) {
const { i18n, pin, onPinGoTo, onPinRemove, onPinsShowAll } = props;
const handlePinGoTo = useCallback(() => {
onPinGoTo(pin.id);
}, [onPinGoTo, pin.id]);
const handlePinRemove = useCallback(() => {
onPinRemove(pin.id);
}, [onPinRemove, pin.id]);
const handlePinsShowAll = useCallback(() => {
onPinsShowAll();
}, [onPinsShowAll]);
return (
<div className={tw('flex min-w-0 flex-1 flex-row items-center')}>
{props.pin.message.attachment != null && (
<ImageThumbnail url={props.pin.message.attachment.url} />
)}
<div className={tw('min-w-0 flex-1')}>
<h1 className={tw('type-body-small font-semibold text-label-primary')}>
<UserText text={props.pin.sender.title} />
</h1>
<p className={tw('me-2 truncate type-body-medium text-label-primary')}>
<UserText text={props.pin.message.body} />
</p>
<AriaClickable.HiddenTrigger
aria-label={i18n(
'icu:PinnedMessagesBar__GoToMessageClickableArea__AccessibilityLabel'
)}
onClick={handlePinGoTo}
/>
</div>
<AriaClickable.SubWidget>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>
<AxoIconButton.Root
variant="borderless-secondary"
size="md"
symbol="pin"
aria-label={i18n(
'icu:PinnedMessagesBar__ActionsMenu__Button__AccessibilityLabel'
)}
/>
</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<AxoDropdownMenu.Item symbol="pin-slash" onSelect={handlePinRemove}>
{i18n('icu:PinnedMessagesBar__ActionsMenu__UnpinMessage')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
symbol="message-arrow"
onSelect={handlePinGoTo}
>
{i18n('icu:PinnedMessagesBar__ActionsMenu__GoToMessage')}
</AxoDropdownMenu.Item>
<AxoDropdownMenu.Item
symbol="list-bullet"
onSelect={handlePinsShowAll}
>
{i18n('icu:PinnedMessagesBar__ActionsMenu__SeeAllMessages')}
</AxoDropdownMenu.Item>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
</AriaClickable.SubWidget>
</div>
);
}
function ImageThumbnail(props: { url: string }) {
return (
<img
alt=""
src={props.url}
className={tw('me-2 size-8 rounded-[10px] object-cover')}
/>
);
}