Skip to content

Commit f1a3a75

Browse files
authored
NTP: Style Omnibar to match specs (#1811)
* Search form and suggestions list styling * Root container styling * Search form and root container spacing tweaks * AI chat form styling * Remove unecessary .icon class * Tab switcher styling * Dark mode * Make AI form same size as search form * Animation * Remove unused import * Remove test for removed feature * Use useLayoutEffect * Support Shift+Enter for newlines in AI chat form * Remove redundant padding-bottom from AiChatForm styles * Refactor icon components to accept SVG props * Remove input padding in search form * Fix broken JSDoc comment * Refactor AI chat form submission and keyboard tests
1 parent 342c5a5 commit f1a3a75

21 files changed

+683
-441
lines changed

special-pages/pages/history/app/components/SearchForm.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import styles from './Header.module.css';
2-
import { h } from 'preact';
3-
import { usePlatformName, useTypedTranslation } from '../types.js';
41
import { useComputed, useSignalEffect } from '@preact/signals';
5-
import { SearchIcon } from '../icons/Search.js';
2+
import { h } from 'preact';
63
import { useQueryDispatch } from '../global/Providers/QueryProvider.js';
4+
import { SearchIcon } from '../icons/Search.js';
5+
import { usePlatformName, useTypedTranslation } from '../types.js';
6+
import styles from './Header.module.css';
77

88
const INPUT_FIELD_NAME = 'q';
99

special-pages/pages/new-tab/app/components/Icons.js

Lines changed: 164 additions & 34 deletions
Large diffs are not rendered by default.
Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import cn from 'classnames';
21
import { h } from 'preact';
3-
import { useContext } from 'preact/hooks';
2+
import { useContext, useRef } from 'preact/hooks';
3+
import { eventToTarget } from '../../../../../shared/handlers';
44
import { ArrowRightIcon } from '../../components/Icons';
5+
import { usePlatformName } from '../../settings.provider';
56
import { useTypedTranslationWith } from '../../types';
6-
import styles from './Omnibar.module.css';
7+
import styles from './AiChatForm.module.css';
78
import { OmnibarContext } from './OmnibarProvider';
89

910
/**
@@ -18,41 +19,76 @@ import { OmnibarContext } from './OmnibarProvider';
1819
export function AiChatForm({ chat, setChat }) {
1920
const { submitChat } = useContext(OmnibarContext);
2021
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
22+
const platformName = usePlatformName();
23+
24+
const formRef = useRef(/** @type {HTMLFormElement|null} */ (null));
25+
const textAreaRef = useRef(/** @type {HTMLTextAreaElement|null} */ (null));
26+
27+
const disabled = chat.length === 0;
2128

2229
/** @type {(event: SubmitEvent) => void} */
2330
const onSubmit = (event) => {
2431
event.preventDefault();
32+
if (disabled) return;
2533
submitChat({
2634
chat,
2735
target: 'same-tab',
2836
});
2937
};
3038

39+
/** @type {(event: KeyboardEvent) => void} */
40+
const onKeyDown = (event) => {
41+
if (event.key === 'Enter' && !event.shiftKey) {
42+
event.preventDefault();
43+
if (disabled) return;
44+
submitChat({
45+
chat,
46+
target: eventToTarget(event, platformName),
47+
});
48+
}
49+
};
50+
51+
/** @type {(event: import('preact').JSX.TargetedEvent<HTMLTextAreaElement>) => void} */
52+
const onChange = (event) => {
53+
const form = formRef.current;
54+
const textArea = event.currentTarget;
55+
56+
const { paddingTop, paddingBottom } = window.getComputedStyle(textArea);
57+
textArea.style.height = 'auto'; // Reset height
58+
textArea.style.height = `calc(${textArea.scrollHeight}px - ${paddingTop} - ${paddingBottom})`;
59+
60+
if (textArea.scrollHeight > textArea.clientHeight) {
61+
form?.classList.add(styles.hasScroll);
62+
} else {
63+
form?.classList.remove(styles.hasScroll);
64+
}
65+
66+
setChat(textArea.value);
67+
};
68+
3169
return (
32-
<div class={styles.formWrap}>
33-
<form onSubmit={onSubmit} class={styles.form}>
34-
<div class={styles.inputRoot} style={{ viewTransitionName: 'omnibar-input-transition' }}>
35-
<div class={styles.inputContainer} style={{ viewTransitionName: 'omnibar-input-transition2' }}>
36-
<input
37-
type="text"
38-
class={styles.input}
39-
value={chat}
40-
placeholder={t('aiChatForm_placeholder')}
41-
aria-label={t('aiChatForm_placeholder')}
42-
autoComplete="off"
43-
onChange={(event) => setChat(event.currentTarget.value)}
44-
/>
45-
<div class={styles.inputActions}>
46-
<button
47-
class={cn(styles.inputAction, styles.squareButton, styles.aiSubmitButton)}
48-
aria-label={t('aiChatForm_submitButtonLabel')}
49-
>
50-
<ArrowRightIcon />
51-
</button>
52-
</div>
53-
</div>
54-
</div>
55-
</form>
56-
</div>
70+
<form ref={formRef} class={styles.form} onClick={() => textAreaRef.current?.focus()} onSubmit={onSubmit}>
71+
<textarea
72+
ref={textAreaRef}
73+
class={styles.textarea}
74+
value={chat}
75+
placeholder={t('aiChatForm_placeholder')}
76+
aria-label={t('aiChatForm_placeholder')}
77+
autoComplete="off"
78+
rows={1}
79+
onKeyDown={onKeyDown}
80+
onChange={onChange}
81+
/>
82+
<div class={styles.buttons}>
83+
<button
84+
type="submit"
85+
class={styles.submitButton}
86+
aria-label={t('aiChatForm_submitButtonLabel')}
87+
disabled={chat.length === 0}
88+
>
89+
<ArrowRightIcon />
90+
</button>
91+
</div>
92+
</form>
5793
);
5894
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
.form {
2+
align-items: center;
3+
display: flex;
4+
flex-direction: column;
5+
padding-bottom: calc(14px + 9px + var(--sp-7)); /* Extra space to accomodate absolute positioned .buttons */
6+
}
7+
8+
.textarea {
9+
align-self: stretch;
10+
background: none;
11+
border: none;
12+
box-sizing: content-box;
13+
color: var(--ntp-text-normal);
14+
max-height: 10lh;
15+
padding: 11px 15px 0;
16+
resize: none;
17+
18+
&:focus {
19+
outline: none;
20+
}
21+
22+
.hasScroll & {
23+
border-bottom: 1px solid var(--ntp-surface-border-color);
24+
padding-bottom: 11px;
25+
}
26+
}
27+
28+
.buttons {
29+
align-self: stretch;
30+
bottom: 0;
31+
justify-content: space-between;
32+
left: 0;
33+
padding: 14px 9px 9px;
34+
position: absolute; /* Fix .buttons to <Container /> so that it animates smoothly when container resizes */
35+
right: 0;
36+
37+
.hasScroll & {
38+
padding-top: 9px;
39+
}
40+
}
41+
42+
.submitButton {
43+
align-items: center;
44+
background: var(--color-blue-50);
45+
border-radius: 100%;
46+
border: none;
47+
color: var(--color-white);
48+
cursor: pointer;
49+
display: flex;
50+
height: var(--sp-7);
51+
justify-content: center;
52+
margin-left: auto;
53+
padding: 0;
54+
width: var(--sp-7);
55+
56+
svg {
57+
height: var(--sp-4);
58+
width: var(--sp-4);
59+
}
60+
61+
&[disabled] {
62+
background: none;
63+
color: var(--color-black-at-30);
64+
cursor: default;
65+
}
66+
67+
&:focus-visible {
68+
box-shadow: var(--focus-ring);
69+
}
70+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { h } from 'preact';
2+
import styles from './Container.module.css';
3+
import { useRef, useLayoutEffect, useState } from 'preact/hooks';
4+
5+
/**
6+
* @param {object} props
7+
* @param {boolean} props.overflow
8+
* @param {import('preact').ComponentChildren} props.children
9+
*/
10+
export function Container({ overflow, children }) {
11+
const contentRef = useRef(/** @type {HTMLDivElement|null} */ (null));
12+
const initialHeight = useRef(/** @type {number|null} */ (null));
13+
const [contentHeight, setContentHeight] = useState(/** @type {number|null} */ (null));
14+
15+
useLayoutEffect(() => {
16+
const content = contentRef.current;
17+
if (!content) return;
18+
19+
initialHeight.current = content.scrollHeight;
20+
setContentHeight(content.scrollHeight);
21+
22+
const resizeObserver = new ResizeObserver(() => setContentHeight(content.scrollHeight));
23+
resizeObserver.observe(content);
24+
return () => resizeObserver.disconnect();
25+
}, []);
26+
27+
return (
28+
<div class={styles.outer} style={{ height: overflow && initialHeight.current ? initialHeight.current : 'auto' }}>
29+
<div class={styles.inner} style={{ height: contentHeight ?? 'auto' }}>
30+
<div ref={contentRef}>{children}</div>
31+
</div>
32+
</div>
33+
);
34+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.outer {
2+
align-self: stretch;
3+
z-index: 1;
4+
}
5+
6+
.inner {
7+
background: var(--ntp-surface-tertiary);
8+
border-radius: 11px;
9+
box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.1), 0px 4px 8px 0px rgba(0, 0, 0, 0.08);
10+
margin: 0 calc(-1 * var(--sp-1));
11+
overflow: hidden;
12+
position: relative;
13+
transition: height 200ms ease;
14+
15+
@media (prefers-reduced-motion: reduce) {
16+
transition: none;
17+
}
18+
19+
&:focus-within {
20+
border-radius: 14px;
21+
box-shadow: 0 0 0 2px var(--ntp-color-primary), 0 0 0 4px rgba(57, 105, 239, 0.2);
22+
}
23+
24+
&:has([role="listbox"]) {
25+
border-radius: 16px;
26+
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.08), 0px 4px 8px 0px rgba(0, 0, 0, 0.16);
27+
}
28+
}
Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { h } from 'preact';
22
import { useState } from 'preact/hooks';
3-
import { AiChatIcon, SearchIcon } from '../../components/Icons.js';
3+
import { LogoStacked } from '../../components/Icons';
44
import { useTypedTranslationWith } from '../../types';
5-
import { viewTransition } from '../../utils';
65
import { AiChatForm } from './AiChatForm';
76
import styles from './Omnibar.module.css';
87
import { SearchForm } from './SearchForm';
8+
import { TabSwitcher } from './TabSwitcher';
9+
import { Container } from './Container';
910

1011
/**
1112
* @typedef {import('../strings.json')} Strings
@@ -23,46 +24,11 @@ export function Omnibar({ mode, setMode, enableAi }) {
2324
const [query, setQuery] = useState(/** @type {String} */ (''));
2425
return (
2526
<div class={styles.root} data-mode={mode}>
26-
<div class={styles.logoWrap}>
27-
<img src="./icons/Logo-Stacked.svg" alt={t('omnibar_logoAlt')} width={144} height={115.9} />
28-
</div>
29-
{enableAi && (
30-
<div class={styles.tabListWrap}>
31-
<div class={styles.tabList} role="tablist" aria-label={t('omnibar_tabSwitcherLabel')}>
32-
<button
33-
class={styles.tab}
34-
role="tab"
35-
aria-selected={mode === 'search'}
36-
onClick={() => {
37-
viewTransition(() => {
38-
setMode('search');
39-
});
40-
}}
41-
>
42-
<SearchIcon className={styles.searchIcon} />
43-
{t('omnibar_searchTabLabel')}
44-
</button>
45-
<button
46-
class={styles.tab}
47-
role="tab"
48-
aria-selected={mode === 'ai'}
49-
onClick={() => {
50-
viewTransition(() => {
51-
setMode('ai');
52-
});
53-
}}
54-
>
55-
<AiChatIcon className={styles.aiChatIcon} />
56-
{t('omnibar_aiTabLabel')}
57-
</button>
58-
</div>
59-
</div>
60-
)}
61-
{mode === 'search' ? (
62-
<SearchForm enableAi={enableAi} term={query} setTerm={setQuery} />
63-
) : (
64-
<AiChatForm chat={query} setChat={setQuery} />
65-
)}
27+
<LogoStacked class={styles.logo} aria-label={t('omnibar_logoAlt')} />
28+
{enableAi && <TabSwitcher mode={mode} setMode={setMode} />}
29+
<Container overflow={mode === 'search'}>
30+
{mode === 'search' ? <SearchForm term={query} setTerm={setQuery} /> : <AiChatForm chat={query} setChat={setQuery} />}
31+
</Container>
6632
</div>
6733
);
6834
}

0 commit comments

Comments
 (0)