style(search): Dawn panel for the switcher and grouped hairline rows for in-room results

This commit is contained in:
heaven 2026-06-04 01:00:23 +03:00
parent 7ea273eca8
commit 5843d75d89
4 changed files with 181 additions and 90 deletions

View file

@ -40,6 +40,7 @@ import {
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce'; import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { SectionLabel } from '../settings/styles.css';
type OrderButtonProps = { type OrderButtonProps = {
order?: string; order?: string;
@ -278,7 +279,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
<MenuItem <MenuItem
data-room-id={roomId} data-room-id={roomId}
onClick={handleRoomClick} onClick={handleRoomClick}
variant={selected ? 'Success' : 'Surface'} variant={selected ? 'Primary' : 'Surface'}
size="300" size="300"
radii="300" radii="300"
aria-pressed={selected} aria-pressed={selected}
@ -364,24 +365,26 @@ export function SearchFilters({
const mx = useMatrixClient(); const mx = useMatrixClient();
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="200">
<Text size="L400">{t('Search.filter')}</Text> <Text as="span" className={SectionLabel}>
{t('Search.filter')}
</Text>
<Box gap="200" wrap="Wrap"> <Box gap="200" wrap="Wrap">
<Chip <Chip
variant={!global ? 'Success' : 'Surface'} variant={!global ? 'Primary' : 'SurfaceVariant'}
aria-pressed={!global} aria-pressed={!global}
before={!global && <Icon size="100" src={Icons.Check} />} before={!global && <Icon size="100" src={Icons.Check} />}
outlined radii="Pill"
onClick={() => onGlobalChange()} onClick={() => onGlobalChange()}
> >
<Text size="T200">{defaultRoomsFilterName}</Text> <Text size="T200">{defaultRoomsFilterName}</Text>
</Chip> </Chip>
{allowGlobal && ( {allowGlobal && (
<Chip <Chip
variant={global ? 'Success' : 'Surface'} variant={global ? 'Primary' : 'SurfaceVariant'}
aria-pressed={global} aria-pressed={global}
before={global && <Icon size="100" src={Icons.Check} />} before={global && <Icon size="100" src={Icons.Check} />}
outlined radii="Pill"
onClick={() => onGlobalChange(true)} onClick={() => onGlobalChange(true)}
> >
<Text size="T200">{t('Search.global')}</Text> <Text size="T200">{t('Search.global')}</Text>
@ -400,7 +403,7 @@ export function SearchFilters({
return ( return (
<Chip <Chip
key={roomId} key={roomId}
variant="Success" variant="Primary"
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
radii="Pill" radii="Pill"
before={ before={

View file

@ -1,7 +1,10 @@
import React, { FormEventHandler, RefObject } from 'react'; import React, { FormEventHandler, RefObject } from 'react';
import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds'; import { Box, Text, Icon, Icons, Spinner, Chip, color, config, toRem } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// Dawn hairline divider (the canon's rgba(255,255,255,0.060.08) row line).
const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)';
type SearchProps = { type SearchProps = {
active?: boolean; active?: boolean;
loading?: boolean; loading?: boolean;
@ -24,46 +27,61 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
}; };
return ( return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}> <Box as="form" direction="Column" onSubmit={handleSearchSubmit}>
<span data-spacing-node /> {/* Integrated hairline search row shares the panel material instead of
<Text size="L400">{t('Search.search')}</Text> a boxed folds Input under a floating «Поиск» label. */}
<Input <Box
ref={searchInputRef} alignItems="Center"
style={{ paddingRight: config.space.S300 }} gap="200"
name="searchInput" style={{
autoFocus paddingTop: config.space.S200,
size="500" paddingBottom: config.space.S200,
variant="Background" borderBottom: HAIRLINE,
placeholder={t('Search.search_for_keyword')} }}
autoComplete="off" >
before={ {active && loading ? (
active && loading ? ( <Spinner variant="Secondary" size="200" />
<Spinner variant="Secondary" size="200" /> ) : (
) : ( <Icon size="200" src={Icons.Search} />
<Icon size="200" src={Icons.Search} /> )}
) <input
} ref={searchInputRef}
after={ name="searchInput"
active ? ( type="text"
<Chip // eslint-disable-next-line jsx-a11y/no-autofocus
key="resetButton" autoFocus
type="reset" placeholder={t('Search.search_for_keyword')}
variant="Secondary" autoComplete="off"
size="400" style={{
radii="Pill" flex: 1,
outlined appearance: 'none',
after={<Icon size="50" src={Icons.Cross} />} border: 'none',
onClick={onReset} outline: 'none',
> background: 'transparent',
<Text size="B300">{t('Search.clear')}</Text> font: 'inherit',
</Chip> fontSize: toRem(15),
) : ( color: color.Surface.OnContainer,
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined> minWidth: 0,
<Text size="B300">{t('Search.enter')}</Text> }}
</Chip> />
) {active ? (
} <Chip
/> key="resetButton"
type="reset"
variant="SurfaceVariant"
size="400"
radii="Pill"
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">{t('Search.clear')}</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill">
<Text size="B300">{t('Search.enter')}</Text>
</Chip>
)}
</Box>
</Box> </Box>
); );
} }

View file

@ -35,7 +35,6 @@ import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
import { ResultItem } from './useMessageSearch'; import { ResultItem } from './useMessageSearch';
import { SequenceCard } from '../../components/sequence-card';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
@ -53,6 +52,9 @@ import {
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
// Dawn hairline for the grouped result-list panel + its internal row dividers.
const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)';
type SearchResultGroupProps = { type SearchResultGroupProps = {
room: Room; room: Room;
highlights: string[]; highlights: string[];
@ -215,8 +217,17 @@ export function SearchResultGroup({
</Text> </Text>
</Box> </Box>
</Header> </Header>
<Box direction="Column" gap="100"> {/* Grouped hairline-divided list one panel, internal row dividers,
{items.map((item) => { instead of a pile of floating SequenceCard cards. */}
<Box
direction="Column"
style={{
border: HAIRLINE,
borderRadius: config.radii.R400,
overflow: 'hidden',
}}
>
{items.map((item, itemIndex) => {
const { event } = item; const { event } = item;
const displayName = const displayName =
@ -247,11 +258,13 @@ export function SearchResultGroup({
const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor;
return ( return (
<SequenceCard <Box
key={event.event_id} key={event.event_id}
style={{ padding: config.space.S400 }}
variant="SurfaceVariant"
direction="Column" direction="Column"
style={{
padding: config.space.S400,
borderBottom: itemIndex < items.length - 1 ? HAIRLINE : undefined,
}}
> >
<ModernLayout <ModernLayout
before={ before={
@ -288,14 +301,14 @@ export function SearchResultGroup({
</Username> </Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />} {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box> </Box>
<Time ts={event.origin_server_ts} /> <Time ts={event.origin_server_ts} style={{ fontFamily: 'var(--font-mono)' }} />
</Box> </Box>
<Box shrink="No" gap="200" alignItems="Center"> <Box shrink="No" gap="200" alignItems="Center">
<Chip <Chip
data-event-id={mainEventId} data-event-id={mainEventId}
onClick={handleOpenClick} onClick={handleOpenClick}
variant="Secondary" variant="SurfaceVariant"
radii="400" radii="Pill"
> >
<Text size="T200">{t('Search.open')}</Text> <Text size="T200">{t('Search.open')}</Text>
</Chip> </Chip>
@ -314,7 +327,7 @@ export function SearchResultGroup({
)} )}
{renderMatrixEvent(event.type, false, event, displayName, getContent)} {renderMatrixEvent(event.type, false, event, displayName, getContent)}
</ModernLayout> </ModernLayout>
</SequenceCard> </Box>
); );
})} })}
</Box> </Box>

View file

@ -2,11 +2,10 @@ import FocusTrap from 'focus-trap-react';
import { import {
Avatar, Avatar,
Box, Box,
color,
config, config,
Icon, Icon,
Icons, Icons,
Input,
Line,
MenuItem, MenuItem,
Modal, Modal,
Overlay, Overlay,
@ -16,7 +15,7 @@ import {
toRem, toRem,
} from 'folds'; } from 'folds';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -35,10 +34,11 @@ import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { searchModalAtom } from '../../state/searchModal'; import { searchModalAtom } from '../../state/searchModal';
import { useKeyDown } from '../../hooks/useKeyDown'; import { useKeyDown } from '../../hooks/useKeyDown';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { KeySymbol } from '../../utils/key-symbol';
import { isMacOS } from '../../utils/user-agent';
import { getDmUserId, useRoomSearch } from './useRoomSearch'; import { getDmUserId, useRoomSearch } from './useRoomSearch';
// Dawn hairline divider (the canon's rgba(255,255,255,0.060.08) row line).
const HAIRLINE = '1px solid rgba(255, 255, 255, 0.08)';
type SearchProps = { type SearchProps = {
requestClose: () => void; requestClose: () => void;
}; };
@ -91,22 +91,45 @@ export function Search({ requestClose }: SearchProps) {
}, },
}} }}
> >
<Modal size="400" style={{ maxHeight: toRem(400), borderRadius: config.radii.R500 }}> <Modal
size="400"
style={{
maxHeight: toRem(400),
borderRadius: config.radii.R400,
backgroundColor: '#181a20',
border: HAIRLINE,
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
}}
>
{/* ── Input row: hairline-divided, integrated (not a boxed Input) ── */}
<Box <Box
shrink="No" shrink="No"
style={{ padding: config.space.S400, paddingBottom: 0 }} alignItems="Center"
direction="Column" gap="200"
style={{
padding: `${toRem(12)} ${config.space.S400}`,
borderBottom: HAIRLINE,
}}
> >
<Input <Icon size="200" src={Icons.Search} />
<input
ref={inputRef} ref={inputRef}
size="500" type="text"
variant="Background"
radii="400"
outlined
placeholder={t('Search.search')} placeholder={t('Search.search')}
before={<Icon size="200" src={Icons.Search} />} autoComplete="off"
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
style={{
flex: 1,
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(15),
color: color.Background.OnContainer,
minWidth: 0,
}}
/> />
</Box> </Box>
<Box grow="Yes"> <Box grow="Yes">
@ -131,7 +154,7 @@ export function Search({ requestClose }: SearchProps) {
)} )}
{roomsToRender.length > 0 && ( {roomsToRender.length > 0 && (
<Scroll ref={scrollRef} size="300" hideTrack> <Scroll ref={scrollRef} size="300" hideTrack>
<div style={{ padding: config.space.S400, paddingRight: config.space.S200 }}> <div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}>
{roomsToRender.map((roomId, index) => { {roomsToRender.map((roomId, index) => {
const room = getRoom(roomId); const room = getRoom(roomId);
if (!room) return null; if (!room) return null;
@ -153,6 +176,7 @@ export function Search({ requestClose }: SearchProps) {
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId); const unread = roomToUnread.get(roomId);
const focused = listFocus.index === index;
return ( return (
<MenuItem <MenuItem
@ -162,19 +186,35 @@ export function Search({ requestClose }: SearchProps) {
data-room-id={roomId} data-room-id={roomId}
data-space={room.isSpaceRoom()} data-space={room.isSpaceRoom()}
onClick={handleRoomClick} onClick={handleRoomClick}
variant={listFocus.index === index ? 'Primary' : 'Surface'} variant="Surface"
aria-pressed={listFocus.index === index} aria-pressed={focused}
radii="400" radii="300"
style={{
// Fleet highlight: a violet tint + a 2px left bar
// instead of a solid Primary fill (Dawn selection).
backgroundColor: focused ? 'rgba(149, 128, 255, 0.08)' : undefined,
boxShadow: focused ? `inset 2px 0 0 ${color.Primary.Main}` : undefined,
}}
after={ after={
<Box gap="100"> <Box gap="100">
{dmUserServer && ( {dmUserServer && (
<Text size="T200" priority="300" truncate> <Text
<b>{dmUserServer}</b> size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
{dmUserServer}
</Text> </Text>
)} )}
{!dm && perfectOrphanParent && ( {!dm && perfectOrphanParent && (
<Text size="T200" priority="300" truncate> <Text
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b> size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}
</Text> </Text>
)} )}
{unread && ( {unread && (
@ -221,7 +261,13 @@ export function Search({ requestClose }: SearchProps) {
: room.name} : room.name}
</Text> </Text>
{dmUsername && ( {dmUsername && (
<Text as="span" size="T200" priority="300" truncate> <Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@ @
{queryHighlightRegex {queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername]) ? highlightText(queryHighlightRegex, [dmUsername])
@ -241,14 +287,25 @@ export function Search({ requestClose }: SearchProps) {
</Scroll> </Scroll>
)} )}
</Box> </Box>
<Line size="300" /> {/* ── Mono keyboard-hint footer (hairline-divided) ── */}
<Box shrink="No" justifyContent="Center" style={{ padding: config.space.S200 }}> <Box
<Text size="T200" priority="300"> shrink="No"
<Trans justifyContent="Center"
i18nKey="Search.help_text" gap="300"
values={{ hotkey: `${isMacOS() ? KeySymbol.Command : 'Ctrl'} + k` }} style={{
components={{ bold: <b /> }} padding: `${toRem(8)} ${config.space.S300}`,
/> borderTop: HAIRLINE,
fontFamily: 'var(--font-mono)',
}}
>
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
{`↑↓ ${t('Search.kbd_select')}`}
</Text>
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
{`${t('Search.kbd_open')}`}
</Text>
<Text size="T200" priority="300" style={{ fontFamily: 'var(--font-mono)' }}>
{`esc ${t('Search.kbd_close')}`}
</Text> </Text>
</Box> </Box>
</Modal> </Modal>