mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Init PinnedMessagesBar UI
This commit is contained in:
@@ -1650,6 +1650,34 @@
|
|||||||
"messageformat": "Pin",
|
"messageformat": "Pin",
|
||||||
"description": "Message > Context Menu > Pin Message > Dialog > Pin Button"
|
"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": {
|
"icu:sessionEnded": {
|
||||||
"messageformat": "Secure session reset",
|
"messageformat": "Secure session reset",
|
||||||
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
|
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// 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, useRef, useState } from 'react';
|
import React, { memo, useCallback, useEffect, 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 { computeAccessibleName } from 'dom-accessibility-api';
|
||||||
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 {
|
import {
|
||||||
@@ -146,6 +147,10 @@ export namespace AriaClickable {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type HiddenTriggerProps = Readonly<{
|
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
|
* This should reference the ID of an element that describes the action that
|
||||||
* will be taken `onClick`, not the entire clickable root.
|
* will be taken `onClick`, not the entire clickable root.
|
||||||
@@ -156,10 +161,12 @@ export namespace AriaClickable {
|
|||||||
* <HiddenTrigger aria-labelledby="see-more-1"/>
|
* <HiddenTrigger aria-labelledby="see-more-1"/>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
'aria-labelledby': string;
|
'aria-labelledby'?: string;
|
||||||
onClick: (event: MouseEvent) => void;
|
onClick: (event: MouseEvent) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const hiddenTriggerDisplayName = `${Namespace}.HiddenTrigger`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides an invisible button that fills the entire area of
|
* Provides an invisible button that fills the entire area of
|
||||||
* `<AriaClickable.Root>`
|
* `<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 (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
className={tw('absolute inset-0 z-10 outline-0')}
|
className={tw('absolute inset-0 z-10 outline-0')}
|
||||||
|
aria-label={props['aria-label']}
|
||||||
aria-labelledby={props['aria-labelledby']}
|
aria-labelledby={props['aria-labelledby']}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
HiddenTrigger.displayName = `${Namespace}.HiddenTrigger`;
|
HiddenTrigger.displayName = hiddenTriggerDisplayName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ const Namespace = 'AxoContextMenu';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export namespace AxoContextMenu {
|
export namespace AxoContextMenu {
|
||||||
export type RootContextType = Readonly<{
|
type RootContextType = Readonly<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const RootContext = createStrictContext<RootContextType>(
|
const RootContext = createStrictContext<RootContextType>(
|
||||||
`${Namespace}.RootContext`
|
`${Namespace}.RootContext`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +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, { memo, useEffect, useId, useRef } from 'react';
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { DropdownMenu } from 'radix-ui';
|
import { DropdownMenu } from 'radix-ui';
|
||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { computeAccessibleName } from 'dom-accessibility-api';
|
import { computeAccessibleName } from 'dom-accessibility-api';
|
||||||
@@ -17,6 +25,10 @@ import {
|
|||||||
getElementAriaRole,
|
getElementAriaRole,
|
||||||
isAriaWidgetRole,
|
isAriaWidgetRole,
|
||||||
} from './_internal/ariaRoles.dom.js';
|
} from './_internal/ariaRoles.dom.js';
|
||||||
|
import {
|
||||||
|
createStrictContext,
|
||||||
|
useStrictContext,
|
||||||
|
} from './_internal/StrictContext.dom.js';
|
||||||
|
|
||||||
const Namespace = 'AxoDropdownMenu';
|
const Namespace = 'AxoDropdownMenu';
|
||||||
|
|
||||||
@@ -63,6 +75,14 @@ const Namespace = 'AxoDropdownMenu';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export namespace AxoDropdownMenu {
|
export namespace AxoDropdownMenu {
|
||||||
|
type RootContextType = Readonly<{
|
||||||
|
open: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const RootContext = createStrictContext<RootContextType>(
|
||||||
|
`${Namespace}.RootContext`
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component: <AxoDropdownMenu.Root>
|
* Component: <AxoDropdownMenu.Root>
|
||||||
* ---------------------------------
|
* ---------------------------------
|
||||||
@@ -71,17 +91,37 @@ export namespace AxoDropdownMenu {
|
|||||||
export type RootProps = AxoBaseMenu.MenuRootProps &
|
export type RootProps = AxoBaseMenu.MenuRootProps &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contains all the parts of a dropdown menu.
|
* Contains all the parts of a dropdown menu.
|
||||||
*/
|
*/
|
||||||
export const Root: FC<RootProps> = memo(props => {
|
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 (
|
return (
|
||||||
<DropdownMenu.Root open={props.open} onOpenChange={props.onOpenChange}>
|
<RootContext.Provider value={context}>
|
||||||
{props.children}
|
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
</DropdownMenu.Root>
|
{props.children}
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</RootContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +142,7 @@ export namespace AxoDropdownMenu {
|
|||||||
* against the trigger.
|
* against the trigger.
|
||||||
*/
|
*/
|
||||||
export const Trigger: FC<TriggerProps> = memo(props => {
|
export const Trigger: FC<TriggerProps> = memo(props => {
|
||||||
|
const context = useStrictContext(RootContext);
|
||||||
const ref = useRef<HTMLButtonElement>(null);
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +163,13 @@ export namespace AxoDropdownMenu {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,31 +36,38 @@ export namespace AxoIconButton {
|
|||||||
const Variants: Record<Variant, TailwindStyles> = {
|
const Variants: Record<Variant, TailwindStyles> = {
|
||||||
secondary: tw(
|
secondary: tw(
|
||||||
'bg-fill-secondary pressed:bg-fill-secondary-pressed',
|
'bg-fill-secondary pressed:bg-fill-secondary-pressed',
|
||||||
|
'data-[axo-dropdownmenu-state=open]:bg-fill-secondary-pressed',
|
||||||
'text-label-primary disabled:text-label-disabled',
|
'text-label-primary disabled:text-label-disabled',
|
||||||
pressedStyles.fillInverted
|
pressedStyles.fillInverted
|
||||||
),
|
),
|
||||||
primary: tw(
|
primary: tw(
|
||||||
'bg-color-fill-primary pressed:bg-color-fill-primary-pressed',
|
'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',
|
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||||
pressedStyles.fillInverted
|
pressedStyles.fillInverted
|
||||||
),
|
),
|
||||||
affirmative: tw(
|
affirmative: tw(
|
||||||
'bg-color-fill-affirmative pressed:bg-color-fill-affirmative-pressed',
|
'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',
|
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||||
pressedStyles.fillInverted
|
pressedStyles.fillInverted
|
||||||
),
|
),
|
||||||
destructive: tw(
|
destructive: tw(
|
||||||
'bg-color-fill-destructive pressed:bg-color-fill-destructive-pressed',
|
'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',
|
'text-label-primary-on-color disabled:text-label-disabled-on-color',
|
||||||
pressedStyles.fillInverted
|
pressedStyles.fillInverted
|
||||||
),
|
),
|
||||||
'borderless-secondary': tw(
|
'borderless-secondary': tw(
|
||||||
'hovered:bg-fill-secondary pressed:bg-fill-secondary-pressed',
|
'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',
|
'text-label-primary disabled:text-label-disabled',
|
||||||
pressedStyles.colorFillPrimary
|
pressedStyles.colorFillPrimary
|
||||||
),
|
),
|
||||||
'floating-secondary': tw(
|
'floating-secondary': tw(
|
||||||
'bg-fill-floating pressed:bg-fill-floating-pressed',
|
'bg-fill-floating pressed:bg-fill-floating-pressed',
|
||||||
|
'data-[axo-dropdownmenu-state=open]:bg-fill-floating-pressed',
|
||||||
'text-label-primary disabled:text-label-disabled',
|
'text-label-primary disabled:text-label-disabled',
|
||||||
'shadow-elevation-1',
|
'shadow-elevation-1',
|
||||||
pressedStyles.fillInverted
|
pressedStyles.fillInverted
|
||||||
@@ -130,7 +137,7 @@ export namespace AxoIconButton {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={tw(
|
className={tw(
|
||||||
'forced-color-adjust-none',
|
'align-top leading-none forced-color-adjust-none',
|
||||||
experimentalSpinner != null ? 'opacity-0' : null
|
experimentalSpinner != null ? 'opacity-0' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 we’re 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user