style(autocomplete): Dawn popover with mono handles, a fleet row highlight and a violet self-mention chip

This commit is contained in:
heaven 2026-06-04 00:59:31 +03:00
parent baf23f9a45
commit 4c6f662939
7 changed files with 78 additions and 35 deletions

View file

@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const AutocompleteMenuBase = style([ export const AutocompleteMenuBase = style([
DefaultReset, DefaultReset,
@ -19,17 +19,51 @@ export const AutocompleteMenuContainer = style([
}, },
]); ]);
// Dawn popover shell — a single hairline-bordered panel that floats above the
// composer, mirroring the cmdK result-list look (panel #181a20 + faint fleet ring).
export const AutocompleteMenu = style([ export const AutocompleteMenu = style([
DefaultReset, DefaultReset,
{ {
maxHeight: '30vh', maxHeight: '40vh',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
border: `${toRem(1)} solid rgba(255, 255, 255, 0.07)`,
borderRadius: config.radii.R400,
boxShadow: '0 30px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(149, 128, 255, 0.15)',
overflow: 'hidden',
}, },
]); ]);
// UPPERCASE, letter-spaced, muted mono section label — replaces the folds Header.
export const AutocompleteMenuHeader = style([ export const AutocompleteMenuHeader = style([
DefaultReset, DefaultReset,
{ padding: `0 ${config.space.S300}`, flexShrink: 0 }, {
flexShrink: 0,
display: 'block',
padding: `${config.space.S200} ${config.space.S300} ${config.space.S100}`,
fontFamily: 'var(--font-mono)',
fontSize: toRem(10),
lineHeight: toRem(14),
fontWeight: config.fontWeight.W500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: color.SurfaceVariant.OnContainer,
opacity: 0.5,
},
]); ]);
// JetBrains Mono technical handle (@user:server, #alias:server, !id, /command sig)
// rendered at a muted tone inside an autocomplete row.
export const AutocompleteMono = style({
fontFamily: 'var(--font-mono)',
opacity: 0.6,
});
// Fleet highlight for the first (Tab-target) row: faint violet wash + a 2px
// fleet left-border. Purely visual — reflects the onTabPress 'first item' logic.
export const AutocompleteActiveRow = style({
backgroundColor: 'rgba(149, 128, 255, 0.08)',
boxShadow: `inset ${toRem(2)} 0 0 0 ${color.Primary.Main}`,
});

View file

@ -1,7 +1,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Header, Menu, Scroll, config } from 'folds'; import { Menu, Scroll, config } from 'folds';
import * as css from './AutocompleteMenu.css'; import * as css from './AutocompleteMenu.css';
import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard'; import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard';
@ -38,9 +38,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
}} }}
> >
<Menu className={css.AutocompleteMenu}> <Menu className={css.AutocompleteMenu}>
<Header className={css.AutocompleteMenuHeader} size="400"> <span className={css.AutocompleteMenuHeader}>{headerContent}</span>
{headerContent}
</Header>
<Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}> <Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
<div style={{ padding: config.space.S200 }}>{children}</div> <div style={{ padding: config.space.S200 }}>{children}</div>
</Scroll> </Scroll>

View file

@ -2,9 +2,11 @@ import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from '
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds'; import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import * as css from './AutocompleteMenu.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
@ -42,6 +44,7 @@ export function EmoticonAutocomplete({
query, query,
requestClose, requestClose,
}: EmoticonAutocompleteProps) { }: EmoticonAutocompleteProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@ -85,8 +88,8 @@ export function EmoticonAutocomplete({
}); });
return autoCompleteEmoticon.length === 0 ? null : ( return autoCompleteEmoticon.length === 0 ? null : (
<AutocompleteMenu headerContent={<Text size="L400">Emojis</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={t('Room.autocomplete_emojis')} requestClose={requestClose}>
{autoCompleteEmoticon.map((emoticon) => { {autoCompleteEmoticon.map((emoticon, index) => {
const isCustomEmoji = 'url' in emoticon; const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode; const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication); const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
@ -94,6 +97,7 @@ export function EmoticonAutocomplete({
return ( return (
<MenuItem <MenuItem
key={emoticon.shortcode + key} key={emoticon.shortcode + key}
className={index === 0 ? css.AutocompleteActiveRow : undefined}
as="button" as="button"
radii="300" radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
@ -121,7 +125,7 @@ export function EmoticonAutocomplete({
) )
} }
> >
<Text style={{ flexGrow: 1 }} size="B400" truncate> <Text className={css.AutocompleteMono} style={{ flexGrow: 1 }} size="B400" truncate>
:{emoticon.shortcode}: :{emoticon.shortcode}:
</Text> </Text>
</MenuItem> </MenuItem>

View file

@ -3,12 +3,14 @@ import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk'; import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getDirectRoomAvatarUrl } from '../../../utils/room'; import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import * as css from './AutocompleteMenu.css';
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix'; import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard'; import { onTabPress } from '../../../utils/keyboard';
@ -76,6 +78,7 @@ export function RoomMentionAutocomplete({
query, query,
requestClose, requestClose,
}: RoomMentionAutocompleteProps) { }: RoomMentionAutocompleteProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
@ -86,12 +89,12 @@ export function RoomMentionAutocomplete({
useCallback( useCallback(
(rId) => { (rId) => {
const r = mx.getRoom(rId); const r = mx.getRoom(rId);
if (!r) return 'Unknown Room'; if (!r) return t('Room.autocomplete_unknown_room');
const alias = r.getCanonicalAlias(); const alias = r.getCanonicalAlias();
if (alias) return [r.name, alias]; if (alias) return [r.name, alias];
return r.name; return r.name;
}, },
[mx] [mx, t]
), ),
SEARCH_OPTIONS SEARCH_OPTIONS
); );
@ -133,11 +136,11 @@ export function RoomMentionAutocomplete({
}); });
return ( return (
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={t('Room.autocomplete_rooms')} requestClose={requestClose}>
{autoCompleteRoomIds.length === 0 ? ( {autoCompleteRoomIds.length === 0 ? (
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} /> <UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : ( ) : (
autoCompleteRoomIds.map((rId) => { autoCompleteRoomIds.map((rId, index) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
if (!room) return null; if (!room) return null;
const dm = mDirects.has(room.roomId); const dm = mDirects.has(room.roomId);
@ -147,6 +150,7 @@ export function RoomMentionAutocomplete({
return ( return (
<MenuItem <MenuItem
key={rId} key={rId}
className={index === 0 ? css.AutocompleteActiveRow : undefined}
as="button" as="button"
radii="300" radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
@ -154,7 +158,7 @@ export function RoomMentionAutocomplete({
} }
onClick={handleSelect} onClick={handleSelect}
after={ after={
<Text size="T200" priority="300" truncate> <Text className={css.AutocompleteMono} size="T200" priority="300" truncate>
{room.getCanonicalAlias() ?? ''} {room.getCanonicalAlias() ?? ''}
</Text> </Text>
} }

View file

@ -2,9 +2,11 @@ import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu'; import { AutocompleteMenu } from './AutocompleteMenu';
import * as css from './AutocompleteMenu.css';
import { useRoomMembers } from '../../../hooks/useRoomMembers'; import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
@ -90,6 +92,7 @@ export function UserMentionAutocomplete({
query, query,
requestClose, requestClose,
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { roomId } = room; const { roomId } = room;
@ -137,7 +140,7 @@ export function UserMentionAutocomplete({
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
return ( return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={t('Room.autocomplete_users')} requestClose={requestClose}>
{query.text === 'room' && ( {query.text === 'room' && (
<UnknownMentionItem <UnknownMentionItem
userId={roomAliasOrId} userId={roomAliasOrId}
@ -152,7 +155,7 @@ export function UserMentionAutocomplete({
handleAutocomplete={handleAutocomplete} handleAutocomplete={handleAutocomplete}
/> />
) : ( ) : (
autoCompleteMembers.map((roomMember) => { autoCompleteMembers.map((roomMember, index) => {
const avatarMxcUrl = roomMember.getMxcAvatarUrl(); const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
@ -160,6 +163,9 @@ export function UserMentionAutocomplete({
return ( return (
<MenuItem <MenuItem
key={roomMember.userId} key={roomMember.userId}
className={
index === 0 && query.text !== 'room' ? css.AutocompleteActiveRow : undefined
}
as="button" as="button"
radii="300" radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) => onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
@ -167,7 +173,7 @@ export function UserMentionAutocomplete({
} }
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))} onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))}
after={ after={
<Text size="T200" priority="300" truncate> <Text className={css.AutocompleteMono} size="T200" priority="300" truncate>
{roomMember.userId} {roomMember.userId}
</Text> </Text>
} }

View file

@ -2,6 +2,7 @@ import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, use
import { Editor } from 'slate'; import { Editor } from 'slate';
import { Box, config, MenuItem, Text } from 'folds'; import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Command, useCommands } from '../../hooks/useCommands'; import { Command, useCommands } from '../../hooks/useCommands';
import { import {
AutocompleteMenu, AutocompleteMenu,
@ -10,6 +11,7 @@ import {
moveCursor, moveCursor,
replaceWithElement, replaceWithElement,
} from '../../components/editor'; } from '../../components/editor';
import * as css from '../../components/editor/autocomplete/AutocompleteMenu.css';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch'; import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
@ -36,6 +38,7 @@ export function CommandAutocomplete({
query, query,
requestClose, requestClose,
}: CommandAutocompleteProps) { }: CommandAutocompleteProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const commands = useCommands(mx, room); const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]); const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
@ -71,17 +74,11 @@ export function CommandAutocomplete({
}); });
return autoCompleteNames.length === 0 ? null : ( return autoCompleteNames.length === 0 ? null : (
<AutocompleteMenu <AutocompleteMenu headerContent={t('Room.autocomplete_commands')} requestClose={requestClose}>
headerContent={ {autoCompleteNames.map((commandName, index) => (
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Commands</Text>
</Box>
}
requestClose={requestClose}
>
{autoCompleteNames.map((commandName) => (
<MenuItem <MenuItem
key={commandName} key={commandName}
className={index === 0 ? css.AutocompleteActiveRow : undefined}
as="button" as="button"
radii="300" radii="300"
style={{ height: 'unset' }} style={{ height: 'unset' }}
@ -97,7 +94,7 @@ export function CommandAutocomplete({
gap="100" gap="100"
justifyContent="SpaceBetween" justifyContent="SpaceBetween"
> >
<Text style={{ flexGrow: 1 }} size="B400" truncate> <Text className={css.AutocompleteMono} style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`} {`/${commandName}`}
</Text> </Text>
<Text truncate priority="300" size="T200"> <Text truncate priority="300" size="T200">

View file

@ -147,9 +147,9 @@ export const Mention = recipe({
base: [ base: [
DefaultReset, DefaultReset,
{ {
backgroundColor: color.SurfaceVariant.Container, // Flat Dawn pill — violet-tinted fill + lavender text, no boxShadow ring.
color: color.SurfaceVariant.OnContainer, backgroundColor: 'rgba(149, 128, 255, 0.12)',
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, color: color.Primary.MainHover,
padding: `0 ${toRem(2)}`, padding: `0 ${toRem(2)}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
fontWeight: config.fontWeight.W500, fontWeight: config.fontWeight.W500,
@ -158,14 +158,14 @@ export const Mention = recipe({
variants: { variants: {
highlight: { highlight: {
true: { true: {
backgroundColor: color.Success.Container, // Self-mention reads as the fleet brand accent (not green status).
color: color.Success.OnContainer, backgroundColor: color.Primary.Container,
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`, color: color.Primary.OnContainer,
}, },
}, },
focus: { focus: {
true: { true: {
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`, boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Primary.Main}`,
}, },
}, },
}, },