diff --git a/special-pages/pages/new-tab/app/InlineError.js b/special-pages/pages/new-tab/app/InlineError.js new file mode 100644 index 000000000..66ce9dee3 --- /dev/null +++ b/special-pages/pages/new-tab/app/InlineError.js @@ -0,0 +1,29 @@ +import { h } from 'preact'; +import { ErrorBoundary } from '../../../shared/components/ErrorBoundary.js'; +import { useMessaging } from './types.js'; + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {string} props.named + * @param {(message: string) => import("preact").ComponentChild} [props.fallback] + */ +export function InlineError({ children, named, fallback }) { + const messaging = useMessaging(); + /** + * @param {any} error + * @param {string} id + */ + const didCatch = (error, id) => { + const message = error?.message || error?.error || 'unknown'; + const composed = `Customizer section '${id}' threw an exception: ` + message; + messaging.reportPageException({ message: composed }); + }; + const inlineMessage = 'A problem occurred with this feature. DuckDuckGo was notified'; + const fallbackElement = fallback?.(inlineMessage) || <p>{inlineMessage}</p>; + return ( + <ErrorBoundary didCatch={(error) => didCatch(error, named)} fallback={fallbackElement}> + {children} + </ErrorBoundary> + ); +} diff --git a/special-pages/pages/new-tab/app/components/App.js b/special-pages/pages/new-tab/app/components/App.js index 57e59e217..ea8052250 100644 --- a/special-pages/pages/new-tab/app/components/App.js +++ b/special-pages/pages/new-tab/app/components/App.js @@ -75,9 +75,7 @@ export function App() { data-browser-panel > <div class={styles.asideContent}> - <div class={styles.asideContentInner}> - <CustomizerDrawer displayChildren={displayChildren} /> - </div> + <CustomizerDrawer displayChildren={displayChildren} /> </div> </aside> )} diff --git a/special-pages/pages/new-tab/app/components/App.module.css b/special-pages/pages/new-tab/app/components/App.module.css index 0c16b26f3..2e15c2dd9 100644 --- a/special-pages/pages/new-tab/app/components/App.module.css +++ b/special-pages/pages/new-tab/app/components/App.module.css @@ -89,6 +89,11 @@ body:has([data-reset-layout="true"]) .tube { .layout[data-animating="true"] & { overflow: hidden; } + + .layout[data-animating="false"] &[aria-hidden=true] { + visibility: hidden; + opacity: 0; + } } .asideContent { @@ -96,11 +101,6 @@ body:has([data-reset-layout="true"]) .tube { width: var(--ntp-drawer-width); } -.asideContentInner { - padding: 1rem; - padding-right: calc(1rem - var(--ntp-drawer-scroll-width)); -} - .asideScroller { &::-webkit-scrollbar { width: var(--ntp-drawer-scroll-width); diff --git a/special-pages/pages/new-tab/app/components/BackgroundProvider.js b/special-pages/pages/new-tab/app/components/BackgroundProvider.js index 6c8fc4ca0..920dab70b 100644 --- a/special-pages/pages/new-tab/app/components/BackgroundProvider.js +++ b/special-pages/pages/new-tab/app/components/BackgroundProvider.js @@ -1,7 +1,8 @@ import { Fragment, h } from 'preact'; +import cn from 'classnames'; import styles from './BackgroundReceiver.module.css'; import { values } from '../customizer/values.js'; -import { useContext } from 'preact/hooks'; +import { useContext, useState } from 'preact/hooks'; import { CustomizerContext } from '../customizer/CustomizerProvider.js'; import { detectThemeFromHex } from '../customizer/utils.js'; @@ -92,20 +93,9 @@ export function BackgroundConsumer({ browser }) { const gradient = values.gradients[background.value]; return ( <Fragment key="gradient"> + <ImageCrossFade src={gradient.path}></ImageCrossFade> <div - class={styles.root} - data-animate="false" - data-testid="BackgroundConsumer" - style={{ - backgroundColor: gradient.fallback, - backgroundImage: `url(${gradient.path})`, - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - }} - /> - <div - class={styles.root} - data-animate="false" + className={styles.root} style={{ backgroundImage: `url(gradients/grain.png)`, backgroundRepeat: 'repeat', @@ -118,19 +108,7 @@ export function BackgroundConsumer({ browser }) { } case 'userImage': { const img = background.value; - return ( - <div - class={styles.root} - data-animate="true" - data-testid="BackgroundConsumer" - style={{ - backgroundImage: `url(${img.src})`, - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center center', - }} - ></div> - ); + return <ImageCrossFade src={img.src} />; } default: { console.warn('Unreachable!'); @@ -138,3 +116,52 @@ export function BackgroundConsumer({ browser }) { } } } + +/** + * @param {object} props + * @param {string} props.src + */ +function ImageCrossFade({ src }) { + /** + * Proxy the image source, so that we can keep the old + * image around whilst the new one is loading. + */ + const [stable, setStable] = useState(src); + /** + * Trigger the animation: + * + * NOTE: this animation is deliberately NOT done purely with CSS-triggered state. + * Whilst debugging in WebKit, I found the technique below to be 100% reliable + * in terms of fading a new image over the top of an existing one. + * + * If you find a better way, please test in webkit-based browsers + */ + return ( + <Fragment> + <img src={stable} class={styles.root} style={{ display: src === stable ? 'none' : 'block' }} /> + <img + src={src} + class={cn(styles.root, styles.over)} + onLoad={(e) => { + const elem = /** @type {HTMLImageElement} */ (e.target); + + // HACK: This is what I needed to force, to get 100% predictability. 🤷 + elem.style.opacity = '0'; + + const anim = elem.animate([{ opacity: '0' }, { opacity: '1' }], { + duration: 250, + iterations: 1, + easing: 'ease-in-out', + fill: 'both', + }); + + // when the fade completes, we want to reset the stable `src`. + // This allows the image underneath to be updated but also allows us to un-mount the fader on top. + anim.onfinish = () => { + setStable(src); + }; + }} + /> + </Fragment> + ); +} diff --git a/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css b/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css index 13b48b58e..9060ff8b1 100644 --- a/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css +++ b/special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css @@ -4,12 +4,12 @@ inset: 0; width: 100vw; height: 100vh; + object-fit: cover; pointer-events: none; &[data-animate="true"] { - transition: all .3s ease-in-out; + transition: background .25s ease-in-out; } - &[data-background-kind="default"][data-theme=dark] { background: var(--default-dark-bg); } @@ -17,3 +17,10 @@ background: var(--default-light-bg); } } + +.under { + opacity: 1; +} +.over { + opacity: 0; +} diff --git a/special-pages/pages/new-tab/app/components/Components.jsx b/special-pages/pages/new-tab/app/components/Components.jsx index af93222f3..053f2fb10 100644 --- a/special-pages/pages/new-tab/app/components/Components.jsx +++ b/special-pages/pages/new-tab/app/components/Components.jsx @@ -4,6 +4,7 @@ import { mainExamples, otherExamples } from './Examples.jsx'; import { useThemes } from '../customizer/themes.js'; import { useSignal } from '@preact/signals'; import { BackgroundConsumer } from './BackgroundProvider.js'; +import { CustomizerThemesContext } from '../customizer/CustomizerProvider.js'; const url = new URL(window.location.href); const list = { @@ -32,18 +33,20 @@ export function Components() { const { main, browser } = useThemes(dataSignal); return ( - <div class={styles.main} data-main-scroller data-theme={main}> - <BackgroundConsumer browser={browser} /> - <div data-content-tube class={styles.contentTube}> - {isolated && <Isolated entries={filtered} e2e={e2e} />} - {!isolated && ( - <Fragment> - <DebugBar id={ids[0]} ids={ids} entries={entries} /> - <Stage entries={/** @type {any} */ (filtered)} /> - </Fragment> - )} + <CustomizerThemesContext.Provider value={{ main, browser }}> + <div class={styles.main} data-main-scroller data-theme={main}> + <BackgroundConsumer browser={browser} /> + <div data-content-tube class={styles.contentTube}> + {isolated && <Isolated entries={filtered} e2e={e2e} />} + {!isolated && ( + <Fragment> + <DebugBar id={ids[0]} ids={ids} entries={entries} /> + <Stage entries={/** @type {any} */ (filtered)} /> + </Fragment> + )} + </div> </div> - </div> + </CustomizerThemesContext.Provider> ); } diff --git a/special-pages/pages/new-tab/app/components/DismissButton.jsx b/special-pages/pages/new-tab/app/components/DismissButton.jsx index 6c746cd84..81b10259a 100644 --- a/special-pages/pages/new-tab/app/components/DismissButton.jsx +++ b/special-pages/pages/new-tab/app/components/DismissButton.jsx @@ -8,12 +8,13 @@ import styles from './DismissButton.module.css'; * @param {object} props * @param {string} [props.className] * @param {() => void} [props.onClick] + * @param {import("preact").ComponentProps<"button"> & Record<string, string>} [props.buttonProps] */ -export function DismissButton({ className, onClick }) { +export function DismissButton({ className, onClick, buttonProps = {} }) { const { t } = useTypedTranslation(); return ( - <button class={cn(styles.btn, className)} onClick={onClick} aria-label={t('ntp_dismiss')} data-testid="dismissBtn"> + <button class={cn(styles.btn, className)} onClick={onClick} aria-label={t('ntp_dismiss')} data-testid="dismissBtn" {...buttonProps}> <Cross /> </button> ); diff --git a/special-pages/pages/new-tab/app/components/DismissButton.module.css b/special-pages/pages/new-tab/app/components/DismissButton.module.css index 4cb07aed2..dcadd9078 100644 --- a/special-pages/pages/new-tab/app/components/DismissButton.module.css +++ b/special-pages/pages/new-tab/app/components/DismissButton.module.css @@ -12,6 +12,13 @@ border-radius: 50%; transition: all .3s; + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + } + &:hover { background-color: var(--color-black-at-9); cursor: pointer; diff --git a/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js index badce3333..6c2d34e83 100644 --- a/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/BackgroundSection.js @@ -42,40 +42,33 @@ export function BackgroundSection({ data, onNav, onUpload, select }) { } return ( - <div class={styles.section}> - <h3 class={styles.sectionTitle}>Background</h3> - <ul class={cn(styles.sectionBody, styles.bgList)} role="radiogroup"> - <li class={styles.bgListItem}> - <DefaultPanel - checked={data.value.background.kind === 'default'} - onClick={() => select({ background: { kind: 'default' } })} - /> - </li> - <li class={styles.bgListItem}> - <ColorPanel - checked={data.value.background.kind === 'color' || data.value.background.kind === 'hex'} - color={displayColor} - onClick={() => onNav('color')} - /> - </li> - <li class={styles.bgListItem}> - <GradientPanel - checked={data.value.background.kind === 'gradient'} - gradient={gradient} - onClick={() => onNav('gradient')} - /> - </li> - <li class={styles.bgListItem}> - <BackgroundImagePanel - checked={data.value.background.kind === 'userImage'} - onClick={() => onNav('image')} - data={data} - upload={onUpload} - browserTheme={browser} - /> - </li> - </ul> - </div> + <ul class={cn(styles.bgList)} role="radiogroup"> + <li class={styles.bgListItem}> + <DefaultPanel + checked={data.value.background.kind === 'default'} + onClick={() => select({ background: { kind: 'default' } })} + /> + </li> + <li class={styles.bgListItem}> + <ColorPanel + checked={data.value.background.kind === 'color' || data.value.background.kind === 'hex'} + color={displayColor} + onClick={() => onNav('color')} + /> + </li> + <li class={styles.bgListItem}> + <GradientPanel checked={data.value.background.kind === 'gradient'} gradient={gradient} onClick={() => onNav('gradient')} /> + </li> + <li class={styles.bgListItem}> + <BackgroundImagePanel + checked={data.value.background.kind === 'userImage'} + onClick={() => onNav('image')} + data={data} + upload={onUpload} + browserTheme={browser} + /> + </li> + </ul> ); } diff --git a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js index 52b61ec5d..8f4c09b25 100644 --- a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.js @@ -1,4 +1,4 @@ -import styles from './CustomizerDrawerInner.module.css'; +import styles from './BrowserThemeSection.module.css'; import cn from 'classnames'; import { h } from 'preact'; import { useComputed } from '@preact/signals'; @@ -11,49 +11,46 @@ import { useComputed } from '@preact/signals'; export function BrowserThemeSection(props) { const current = useComputed(() => props.data.value.theme); return ( - <div class={styles.section}> - <h3 class={styles.sectionTitle}>Browser Theme</h3> - <ul class={cn(styles.sectionBody, styles.themeList)}> - <li class={styles.themeItem}> - <button - class={styles.themeButton} - role="radio" - type="button" - aria-checked={current.value === 'light'} - tabindex={0} - onClick={() => props.setTheme({ theme: 'light' })} - > - <span class="sr-only">Select light theme</span> - </button> - Light - </li> - <li class={styles.themeItem}> - <button - class={styles.themeButton} - role="radio" - type="button" - aria-checked={current.value === 'dark'} - tabindex={0} - onClick={() => props.setTheme({ theme: 'dark' })} - > - <span className="sr-only">Select dark theme</span> - </button> - Dark - </li> - <li class={styles.themeItem}> - <button - class={styles.themeButton} - role="radio" - type="button" - aria-checked={current.value === 'system'} - tabindex={0} - onClick={() => props.setTheme({ theme: 'system' })} - > - <span className="sr-only">Select system theme</span> - </button> - System - </li> - </ul> - </div> + <ul class={styles.themeList}> + <li class={styles.themeItem}> + <button + class={cn(styles.themeButton, styles.themeButtonLight)} + role="radio" + type="button" + aria-checked={current.value === 'light'} + tabindex={0} + onClick={() => props.setTheme({ theme: 'light' })} + > + <span class="sr-only">Select light theme</span> + </button> + Light + </li> + <li class={styles.themeItem}> + <button + class={cn(styles.themeButton, styles.themeButtonDark)} + role="radio" + type="button" + aria-checked={current.value === 'dark'} + tabindex={0} + onClick={() => props.setTheme({ theme: 'dark' })} + > + <span class="sr-only">Select dark theme</span> + </button> + Dark + </li> + <li class={styles.themeItem}> + <button + class={cn(styles.themeButton, styles.themeButtonSystem)} + role="radio" + type="button" + aria-checked={current.value === 'system'} + tabindex={0} + onClick={() => props.setTheme({ theme: 'system' })} + > + <span class="sr-only">Select system theme</span> + </button> + System + </li> + </ul> ); } diff --git a/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css new file mode 100644 index 000000000..9a9e69ec7 --- /dev/null +++ b/special-pages/pages/new-tab/app/customizer/components/BrowserThemeSection.module.css @@ -0,0 +1,72 @@ +.themeList { + display: flex; + gap: 18px; + --chip-size: 42px; + --chip-size-half: calc(var(--chip-size) / 2); +} +.themeItem { + display: grid; + justify-items: center; + grid-row-gap: 4px; +} +.themeButton { + display: block; + width: var(--chip-size); + height: var(--chip-size); + border-radius: 50%; + + &[aria-checked="true"] { + outline: 2px solid var(--ntp-color-primary); + outline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid var(--ntp-focus-outline-color); + outline-offset: 2px; + } + + &:active { + opacity: .9; + } +} + +.themeButtonLight { + border: 1px solid var(--color-black-at-12); + background: white; +} + +.themeButtonDark { + border: 1px solid var(--color-white-at-9); + background: var(--color-gray-80); +} + +.themeButtonSystem { + position: relative; + border: none; + background: transparent; + width: var(--chip-size); + height: var(--chip-size); + display: flex; + padding: 0; + margin: 0; + + &:before { + content: " "; + display: block; + width: var(--chip-size-half); + height: var(--chip-size); + border-radius: 0 var(--chip-size-half) var(--chip-size-half) 0; + transform: rotate(180deg); + border: 1px solid var(--color-black-at-12); + background: white; + } + &:after { + content: " "; + display: block; + width: var(--chip-size-half); + height: var(--chip-size); + border-radius: 0 var(--chip-size-half) var(--chip-size-half) 0; + border: 1px solid var(--color-white-at-9); + background: var(--color-gray-80); + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js b/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js index 0f40712eb..36614cf1a 100644 --- a/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js +++ b/special-pages/pages/new-tab/app/customizer/components/ColorSelection.js @@ -6,6 +6,7 @@ import styles from './CustomizerDrawerInner.module.css'; import { BackChevron, Picker } from '../../components/Icons.js'; import { useComputed } from '@preact/signals'; import { detectThemeFromHex } from '../utils.js'; +import { InlineError } from '../../InlineError.js'; /** * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, PredefinedColor, BackgroundData } from '../../../types/new-tab.js' @@ -18,29 +19,17 @@ import { detectThemeFromHex } from '../utils.js'; * @param {() => void} props.back */ export function ColorSelection({ data, select, back }) { - console.log(' RENDER:ColorSelection?'); - function onClick(event) { let target = /** @type {HTMLElement|null} */ (event.target); - while (target && target !== event.currentTarget) { - if (target.getAttribute('role') === 'radio') { - event.preventDefault(); - event.stopImmediatePropagation(); - if (target.getAttribute('aria-checked') === 'false') { - if (target.dataset.key) { - const value = /** @type {PredefinedColor} */ (target.dataset.key); - select({ background: { kind: 'color', value } }); - } else { - console.warn('missing dataset.key'); - } - } else { - console.log('ignoring click on selected color'); - } - break; - } else { - target = target.parentElement; - } + const selector = `[role="radio"][aria-checked="false"][data-value]`; + if (!target?.matches(selector)) { + target = /** @type {HTMLElement|null} */ (target?.closest(selector)); } + if (!target) return; + const value = /** @type {PredefinedColor} */ (target.dataset.value); + // todo: report exception? + if (!(value in values.colors)) return console.warn('could not select color', value); + select({ background: { kind: 'color', value } }); } return ( @@ -50,10 +39,12 @@ export function ColorSelection({ data, select, back }) { Solid Colors </button> <div class={styles.sectionBody}> - <div class={cn(styles.bgList)} role="radiogroup" onClick={onClick}> - <PickerPanel data={data} select={select} /> - <ColorGrid data={data} /> - </div> + <InlineError named={'ColorGrid'}> + <div class={cn(styles.bgList)} role="radiogroup" onClick={onClick}> + <PickerPanel data={data} select={select} /> + <ColorGrid data={data} /> + </div> + </InlineError> </div> </div> ); @@ -79,7 +70,7 @@ function ColorGrid({ data }) { style={{ background: entry.hex }} role="radio" aria-checked={key === selected.value} - data-key={key} + data-value={key} > <span class="sr-only">Select {key}</span> </button> @@ -140,7 +131,7 @@ function PickerPanel({ data, select }) { } }} /> - <span class={cn(styles.colorInputIcon, styles.dynamicIconColor)} data-color-mode={modeSelected}> + <span class={cn(styles.colorInputIcon, styles.dynamicPickerIconColor)} data-color-mode={modeSelected}> <Picker /> </span> </div> diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js index 418ff72c1..06e727266 100644 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js +++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.examples.js @@ -1,7 +1,7 @@ -import { h, Fragment } from 'preact'; +import { h } from 'preact'; import { noop } from '../../utils.js'; import { CustomizerButton } from './Customizer.js'; -import { VisibilityMenu } from './VisibilityMenu.js'; +import { EmbeddedVisibilityMenu, VisibilityMenu } from './VisibilityMenu.js'; import { BackgroundSection } from './BackgroundSection.js'; import { ColorSelection } from './ColorSelection.js'; import { GradientSelection } from './GradientSelection.js'; @@ -64,13 +64,32 @@ export const customizerExamples = { }, 'customizer-menu': { factory: () => ( - <Fragment> - <div> - <CustomizerButton isOpen={true} /> - </div> + <MaxContent> + <CustomizerButton isOpen={true} /> + <br /> + <VisibilityMenu + rows={[ + { + id: 'favorites', + title: 'Favorites', + icon: 'star', + toggle: noop('toggle favorites'), + visibility: 'hidden', + index: 0, + }, + { + id: 'privacyStats', + title: 'Privacy Stats', + icon: 'shield', + toggle: noop('toggle favorites'), + visibility: 'visible', + index: 1, + }, + ]} + /> <br /> - <MaxContent> - <VisibilityMenu + <div style="width: 206px; border: 1px dotted black"> + <EmbeddedVisibilityMenu rows={[ { id: 'favorites', @@ -90,8 +109,8 @@ export const customizerExamples = { }, ]} /> - </MaxContent> - </Fragment> + </div> + </MaxContent> ), }, }; diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.js b/special-pages/pages/new-tab/app/customizer/components/Customizer.js index 1abddb9f9..42626b7e8 100644 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.js +++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.js @@ -46,7 +46,7 @@ export function Customizer() { <CustomizerButton buttonId={BUTTON_ID} menuId={MENU_ID} toggleMenu={toggleMenu} buttonRef={buttonRef} isOpen={isOpen} /> <div id={MENU_ID} class={cn(styles.dropdownMenu, { [styles.show]: isOpen })} aria-labelledby={BUTTON_ID}> <VisibilityMenuPopover> - <VisibilityMenu rows={rowData} variant={'popover'} /> + <VisibilityMenu rows={rowData} /> </VisibilityMenuPopover> </div> </div> diff --git a/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css b/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css index b7b4c0f5a..e4f64cfc3 100644 --- a/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/Customizer.module.css @@ -21,8 +21,8 @@ /** todo: is this a re-usable button, yet? */ .customizeButton { - background-color: transparent; - border: 1px solid var(--color-black-at-9); + background-color: var(--ntp-surface-background-color); + border: 1px solid var(--ntp-surface-border-color); border-radius: var(--border-radius-sm); padding: var(--sp-2) var(--sp-3); cursor: pointer; diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js index c981cecc7..d7ea04479 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.js @@ -1,4 +1,5 @@ -import { h } from 'preact'; +import { Fragment, h } from 'preact'; +import cn from 'classnames'; import styles from './CustomizerDrawerInner.module.css'; import { useDrawerControls } from '../../components/Drawer.js'; import { BackgroundSection } from './BackgroundSection.js'; @@ -6,8 +7,12 @@ import { BrowserThemeSection } from './BrowserThemeSection.js'; import { VisibilityMenuSection } from './VisibilityMenuSection.js'; import { ColorSelection } from './ColorSelection.js'; import { GradientSelection } from './GradientSelection.js'; -import { useSignal } from '@preact/signals'; +import { batch, useSignal } from '@preact/signals'; import { ImageSelection } from './ImageSelection.js'; +import { BorderedSection, CustomizerSection } from './CustomizerSection.js'; +import { SettingsLink } from './SettingsLink.js'; +import { DismissButton } from '../../components/DismissButton.jsx'; +import { InlineError } from '../../InlineError.js'; /** * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData } from '../../../types/new-tab.js' @@ -23,27 +28,98 @@ import { ImageSelection } from './ImageSelection.js'; */ export function CustomizerDrawerInner({ data, select, onUpload, setTheme, deleteImage }) { const { close } = useDrawerControls(); - const state = useSignal('home'); - function onNav(nav) { - state.value = nav; - } - function back() { - state.value = 'home'; - } return ( <div class={styles.root}> - <header class={styles.header}> + <header class={cn(styles.header, styles.internal)}> <h2>Customize</h2> - <button onClick={close}>Close</button> + <DismissButton + onClick={close} + className={styles.closeBtn} + buttonProps={{ + 'aria-label': 'Close', + }} + /> </header> - {state.value === 'home' && <BackgroundSection data={data} onNav={onNav} onUpload={onUpload} select={select} />} - {state.value === 'home' && <BrowserThemeSection data={data} setTheme={setTheme} />} - {state.value === 'home' && <VisibilityMenuSection />} - {state.value === 'color' && <ColorSelection data={data} select={select} back={back} />} - {state.value === 'gradient' && <GradientSelection data={data} select={select} back={back} />} - {state.value === 'image' && ( - <ImageSelection data={data} select={select} back={back} onUpload={onUpload} deleteImage={deleteImage} /> - )} + <InlineError + named="Customizer Drawer" + fallback={(message) => ( + <div class={styles.internal}> + <p>{message}</p> + </div> + )} + > + <TwoCol + left={({ push }) => ( + <div class={styles.sections}> + <CustomizerSection title={'Background'}> + <BackgroundSection data={data} onNav={push} onUpload={onUpload} select={select} /> + </CustomizerSection> + <CustomizerSection title={'Browser Theme'}> + <BrowserThemeSection data={data} setTheme={setTheme} /> + </CustomizerSection> + <CustomizerSection title={'Sections'}> + <VisibilityMenuSection /> + </CustomizerSection> + <BorderedSection> + <SettingsLink /> + </BorderedSection> + </div> + )} + right={({ id, pop }) => ( + <Fragment> + {id === 'color' && <ColorSelection data={data} select={select} back={pop} />} + {id === 'gradient' && <GradientSelection data={data} select={select} back={pop} />} + {id === 'image' && ( + <ImageSelection data={data} select={select} back={pop} onUpload={onUpload} deleteImage={deleteImage} /> + )} + </Fragment> + )} + /> + </InlineError> + </div> + ); +} + +/** + * @param {object} props + * @param {(args: {push: (id: string) => void}) => import('preact').ComponentChild} props.left + * @param {(args: {id: string, pop: () => void}) => import('preact').ComponentChild} props.right + */ +function TwoCol({ left, right }) { + const visibleScreen = useSignal('home'); + const renderedScreen = useSignal('home'); + const col1 = useSignal(true); + + /** + * @param {string} id + */ + function push(id) { + visibleScreen.value = id; + requestAnimationFrame(() => { + renderedScreen.value = id; + }); + } + + function pop() { + batch(() => { + col1.value = true; + visibleScreen.value = 'home'; + }); + } + + function transitionEnded() { + if (visibleScreen.value !== 'home') { + col1.value = false; + } + renderedScreen.value = visibleScreen.value; + } + + return ( + <div class={styles.colwrap}> + <div class={styles.cols} data-sub={visibleScreen} onTransitionEnd={transitionEnded}> + <div class={cn(styles.col, styles.col1)}>{col1.value && left({ push })}</div> + <div class={cn(styles.col, styles.col2)}>{renderedScreen.value !== 'home' && right({ id: renderedScreen.value, pop })}</div> + </div> </div> ); } diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css index 2a0b116c3..cd58513d8 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerDrawerInner.module.css @@ -3,20 +3,48 @@ animation-fill-mode: forwards; animation-timing-function: ease-in-out; animation-duration: .1s; - padding-block: 1rem; - display: grid; - grid-auto-rows: max-content; - grid-row-gap: var(--sp-4); font-size: var(--small-label-font-size); line-height: var(--small-label-line-height); font-weight: var(--small-label-font-weight); } +.sections { + display: grid; + grid-auto-rows: max-content; + grid-row-gap: var(--sp-9); +} + .header { display: flex; justify-content: space-between; + align-items: center; } +.internal { + padding-top: 1rem; + padding-left: 1rem; + padding-right: calc(1rem - var(--ntp-drawer-scroll-width)); +} + +.closeBtn { + width: 24px; + height: 24px; + position: relative; + + color: var(--color-black-at-84); + background-color: var(--color-black-at-9); + &:hover { + background-color: var(--color-black-at-18); + } + + [data-theme="dark"] & { + color: var(--color-white-at-84); + background-color: var(--color-white-at-9); + &:hover { + background-color: var(--color-white-at-18); + } + } +} .backBtn { background: none; border: none; @@ -41,6 +69,10 @@ .section { width: 100%; } +.borderedSection { + border-top: 1px solid var(--ntp-surface-border-color); + padding-top: calc(18 * var(--px-in-rem)) +} .sectionBody { margin-top: 16px; } @@ -55,9 +87,10 @@ grid-template-rows: max-content max-content; grid-gap: 12px; } + .bgListItem { display: grid; - grid-row-gap: 6px; + grid-row-gap: 4px; white-space: nowrap; position: relative; @@ -69,39 +102,50 @@ } .bgPanel { display: grid; - aspect-ratio: 16/10; + aspect-ratio: 16/10.6; border-radius: 4px; align-items: center; justify-content: center; border: none; outline: none; + box-shadow: 0 0 0 1px var(--ntp-surface-border-color) inset; &[aria-checked="true"] { - outline: 3px solid var(--ntp-color-primary); + outline: 2px solid var(--ntp-color-primary); outline-offset: 2px; } &:focus-visible { - outline: 3px solid var(--ntp-focus-outline-color); + outline: 2px solid var(--ntp-focus-outline-color); outline-offset: 2px; } + &:active { opacity: .9; } } .bgPanelEmpty { - border: 1px solid var(--ntp-surface-border-color); background-color: rgba(0, 0, 0, 0.03); [data-theme=dark] & { background-color: rgba(255, 255, 255, 0.06); } } -.bgPanelOutlined { - border: 1px solid var(--ntp-surface-border-color); - background-color: transparent; -} .dynamicIconColor { + &[data-color-mode="light"] { + color: black; + svg path { + fill-opacity: 0.6; + } + } + &[data-color-mode="dark"] { + color: white; + svg path { + fill-opacity: 0.6; + } + } +} +.dynamicPickerIconColor { &[data-color-mode="light"] { color: black; svg path { @@ -124,34 +168,6 @@ pointer-events: none; } } -.themeList { - - display: flex; - gap: 18px; -} -.themeItem { - display: grid; - justify-items: center; - grid-row-gap: 6px; -} -.themeButton { - display: block; - width: 42px; - height: 42px; - border-radius: 50%; - border: 1px solid #0000001F; - &[aria-checked="true"] { - outline: 3px solid var(--ntp-color-primary); - outline-offset: 2px; - } - &:focus-visible { - outline: 3px solid var(--ntp-focus-outline-color); - outline-offset: 2px; - } - &:active { - opacity: .9; - } -} @keyframes fade-in { 0% { @@ -170,4 +186,77 @@ position: absolute; top: 4px; right: 4px; + + &[data-color-mode="light"] { + background-color: var(--color-black-at-60); + color: white; + &:hover { + background-color: black; + } + } + + &[data-color-mode="dark"] { + background-color: var(--color-white-at-60); + color: black; + &:hover { + background-color: white; + } + } + + &:focus-visible { + opacity: 1; + } } + +.colwrap { + width: 100%; + overflow: hidden; +} +.cols { + display: flex; + transition: transform .3s ease-in-out; + > * { + flex-shrink: 0; + width: 100%; + } + &:not([data-sub=home]) { + transform: translateX(-100%); + .col1 { + opacity: 0; + visibility: hidden; + } + } + &[data-sub=home] { + .col2 { + opacity: 0; + visibility: hidden; + } + } +} +.col { + padding: 1rem; + padding-right: calc(1rem - var(--ntp-drawer-scroll-width)); + padding-top: 24px; + opacity: 1; + visibility: visible; + transition: opacity .3s linear, visibility .3s linear; +} + +.col1 {} +.col2 {} + +.settingsLink { + display: flex; + font-size: 13px; + justify-content: space-between; + align-items: center; + text-decoration: none; + color: var(--ntp-color-primary); + + &:focus { + outline: none; + } + &:focus-visible { + text-decoration: underline; + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerSection.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerSection.js new file mode 100644 index 000000000..4daf6a217 --- /dev/null +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerSection.js @@ -0,0 +1,30 @@ +import styles from './CustomizerDrawerInner.module.css'; +import { Fragment, h } from 'preact'; +import cn from 'classnames'; + +/** + * @param {object} props + * @param {import("preact").ComponentChild | null} props.title + * @param {import("preact").ComponentChild} props.children + */ +export function CustomizerSection({ title, children }) { + return ( + <div className={styles.section}> + {title === null && children} + {title !== null && ( + <Fragment> + <h3 className={styles.sectionTitle}>{title}</h3> + <div className={styles.sectionBody}>{children}</div> + </Fragment> + )} + </div> + ); +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function BorderedSection({ children }) { + return <div class={cn(styles.section, styles.borderedSection)}>{children}</div>; +} diff --git a/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js b/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js index 410e26c7c..8e2d65aae 100644 --- a/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js +++ b/special-pages/pages/new-tab/app/customizer/components/GradientSelection.js @@ -5,6 +5,7 @@ import { values } from '../values.js'; import styles from './CustomizerDrawerInner.module.css'; import { useComputed } from '@preact/signals'; import { BackChevron } from '../../components/Icons.js'; +import { InlineError } from '../../InlineError.js'; /** * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, BackgroundData, CustomizerData, PredefinedGradient } from '../../../types/new-tab.js' @@ -21,25 +22,15 @@ export function GradientSelection({ data, select, back }) { function onClick(event) { let target = /** @type {HTMLElement|null} */ (event.target); - while (target && target !== event.currentTarget) { - if (target.getAttribute('role') === 'radio') { - event.preventDefault(); - event.stopImmediatePropagation(); - if (target.getAttribute('aria-checked') === 'false') { - if (target.dataset.key) { - const value = /** @type {PredefinedGradient} */ (target.dataset.key); - select({ background: { kind: 'gradient', value } }); - } else { - console.warn('missing dataset.key'); - } - } else { - console.log('ignoring click on selected color'); - } - break; - } else { - target = target.parentElement; - } + const selector = `[role="radio"][aria-checked="false"][data-value]`; + if (!target?.matches(selector)) { + target = /** @type {HTMLElement|null} */ (target?.closest(selector)); } + if (!target) return; + const value = /** @type {PredefinedGradient} */ (target.dataset.value); + // todo: report exception? + if (!(value in values.gradients)) return console.warn('could not select gradient', value); + select({ background: { kind: 'gradient', value } }); } return ( @@ -49,7 +40,9 @@ export function GradientSelection({ data, select, back }) { Gradients </button> <div className={styles.sectionBody} onClick={onClick}> - <GradientGrid data={data} /> + <InlineError named={'GradientSelection'}> + <GradientGrid data={data} /> + </InlineError> </div> </div> ); @@ -73,8 +66,9 @@ function GradientGrid({ data }) { tabIndex={0} role="radio" aria-checked={key === selected.value} - data-key={key} + data-value={key} style={{ + backgroundColor: entry.fallback, backgroundImage: `url(${entry.path})`, backgroundSize: 'cover', backgroundRepeat: 'no-repeat', diff --git a/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js index dd8fe8ede..b3924de66 100644 --- a/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js +++ b/special-pages/pages/new-tab/app/customizer/components/ImageSelection.js @@ -4,9 +4,16 @@ import cn from 'classnames'; import styles from './CustomizerDrawerInner.module.css'; import { useComputed } from '@preact/signals'; import { DismissButton } from '../../components/DismissButton.jsx'; -import { BackChevron } from '../../components/Icons.js'; +import { BackChevron, PlusIcon } from '../../components/Icons.js'; +import { useContext } from 'preact/hooks'; +import { CustomizerThemesContext } from '../CustomizerProvider.js'; +import { InlineError } from '../../InlineError.js'; +import { useTypedTranslationWith } from '../../types.js'; /** + * @import enStrings from '../strings.json'; + * @import ntpStrings from '../../strings.json'; + * @typedef {enStrings & ntpStrings} strings * @import { Widgets, WidgetConfigItem, WidgetVisibility, VisibilityMenuItem, CustomizerData, BackgroundData, PredefinedGradient } from '../../../types/new-tab.js' */ @@ -19,32 +26,18 @@ import { BackChevron } from '../../components/Icons.js'; * @param {(id: string) => void} props.deleteImage */ export function ImageSelection({ data, select, back, onUpload, deleteImage }) { - // const gradient = values.gradients.gradient02; - + const { t } = useTypedTranslationWith(/** @type {strings} */ ({})); function onClick(event) { let target = /** @type {HTMLElement|null} */ (event.target); - while (target && target !== event.currentTarget) { - if (target.getAttribute('role') === 'radio') { - event.preventDefault(); - event.stopImmediatePropagation(); - if (target.getAttribute('aria-checked') === 'false') { - if (target.dataset.key) { - const value = /** @type {string} */ (target.dataset.key); - const match = data.value.userImages.find((i) => i.id === value); - if (match) { - select({ background: { kind: 'userImage', value: match } }); - } - } else { - console.warn('missing dataset.key'); - } - } else { - console.log('ignoring click on selected color'); - } - break; - } else { - target = target.parentElement; - } + const selector = `[role="radio"][aria-checked="false"][data-id]`; + if (!target?.matches(selector)) { + target = /** @type {HTMLElement|null} */ (target?.closest(selector)); } + if (!target) return; + const value = /** @type {string} */ (target.dataset.id); + const match = data.value.userImages.find((i) => i.id === value); + if (!match) return console.warn('could not find matching image', value); + select({ background: { kind: 'userImage', value: match } }); } return ( @@ -54,7 +47,12 @@ export function ImageSelection({ data, select, back, onUpload, deleteImage }) { My Backgrounds </button> <div className={styles.sectionBody} onClick={onClick}> - <ImageGrid data={data} deleteImage={deleteImage} onUpload={onUpload} /> + <InlineError named={'Image Selection'}> + <ImageGrid data={data} deleteImage={deleteImage} onUpload={onUpload} /> + </InlineError> + </div> + <div className={styles.sectionBody}> + <p>{t('customizer_image_privacy')}</p> </div> </div> ); @@ -67,13 +65,22 @@ export function ImageSelection({ data, select, back, onUpload, deleteImage }) { * @param {() => void} props.onUpload */ function ImageGrid({ data, deleteImage, onUpload }) { + const { browser } = useContext(CustomizerThemesContext); const selected = useComputed(() => data.value.background.kind === 'userImage' && data.value.background.value.id); const entries = useComputed(() => { return data.value.userImages; }); + const max = 8; + const diff = max - entries.value.length; + const placeholders = new Array(diff).fill(null); + return ( <ul className={cn(styles.bgList)}> - {entries.value.map((entry) => { + {entries.value.map((entry, index) => { + // eslint-disable-next-line no-labels,no-unused-labels + $INTEGRATION: (() => { + if (entry.id === '__will_throw__') throw new Error('Simulated error'); + })(); return ( <li className={styles.bgListItem} key={entry.id}> <button @@ -82,22 +89,41 @@ function ImageGrid({ data, deleteImage, onUpload }) { tabIndex={0} role="radio" aria-checked={entry.id === selected.value} - data-key={entry.id} + data-id={entry.id} style={{ backgroundImage: `url(${entry.thumb})`, backgroundSize: 'cover', backgroundRepeat: 'no-repeat', }} - ></button> - <DismissButton className={styles.deleteBtn} onClick={() => deleteImage(entry.id)} /> + > + <span class="sr-only">Select image {index + 1}</span> + </button> + <DismissButton + className={styles.deleteBtn} + onClick={() => deleteImage(entry.id)} + buttonProps={{ + 'data-color-mode': String(entry.colorScheme), + 'aria-label': `Delete image ${index + 1}`, + }} + /> + </li> + ); + })} + {placeholders.map((_, index) => { + return ( + <li className={styles.bgListItem} key={`placeholder-${diff}-${index}`}> + <button + type="button" + onClick={onUpload} + class={cn(styles.bgPanel, styles.bgPanelEmpty, styles.dynamicIconColor)} + data-color-mode={browser} + > + <PlusIcon /> + <span class="sr-only">Add Background</span> + </button> </li> ); })} - <li className={styles.bgListItem}> - <button type="button" onClick={onUpload}> - Add image - </button> - </li> </ul> ); } diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js index 8a7c374aa..51230a244 100644 --- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js +++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.js @@ -1,10 +1,13 @@ import { h } from 'preact'; import cn from 'classnames'; -import { useId } from 'preact/hooks'; +import { useId, useContext } from 'preact/hooks'; import { DuckFoot, Shield } from '../../components/Icons.js'; import styles from './VisibilityMenu.module.css'; import { useTypedTranslation } from '../../types.js'; +import { Switch } from '../../../../../shared/components/Switch/Switch.js'; +import { usePlatformName } from '../../settings.provider.js'; +import { CustomizerThemesContext } from '../CustomizerProvider.js'; /** * @import { Widgets, WidgetConfigItem } from '../../../types/new-tab.js' @@ -17,13 +20,12 @@ import { useTypedTranslation } from '../../types.js'; * * @param {object} props * @param {VisibilityRowData[]} props.rows - * @param {'popover' | 'embedded'} [props.variant] */ -export function VisibilityMenu({ rows, variant = 'popover' }) { +export function VisibilityMenu({ rows }) { const MENU_ID = useId(); return ( - <ul className={cn(styles.list, variant === 'embedded' && styles.embedded)}> + <ul className={cn(styles.list)}> {rows.map((row) => { return ( <li key={row.id}> @@ -61,6 +63,42 @@ export function VisibilityMenu({ rows, variant = 'popover' }) { ); } +/** + * @param {object} props + * @param {VisibilityRowData[]} props.rows + */ +export function EmbeddedVisibilityMenu({ rows }) { + const platformName = usePlatformName(); + const { browser } = useContext(CustomizerThemesContext); + return ( + <ul className={cn(styles.list, styles.embedded)}> + {rows.map((row) => { + return ( + <li key={row.id}> + <div class={cn(styles.menuItemLabel, styles.menuItemLabelEmbedded)}> + <span className={styles.svg}> + {row.icon === 'shield' && <DuckFoot />} + {row.icon === 'star' && <Shield />} + </span> + <span>{row.title ?? row.id}</span> + <Switch + theme={browser.value} + platformName={platformName} + checked={row.visibility === 'visible'} + size="medium" + onChecked={() => row.toggle?.(row.id)} + onUnchecked={() => row.toggle?.(row.id)} + ariaLabel={`Toggle ${row.title}`} + pending={false} + /> + </div> + </li> + ); + })} + </ul> + ); +} + export function Heading() { const { t } = useTypedTranslation(); return <h2 className="sr-only">{t('widgets_visibility_menu_title')}</h2>; diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css index df78c99b9..dfaabd03b 100644 --- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css +++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenu.module.css @@ -25,6 +25,7 @@ .embedded { font-size: var(--small-label-font-size); + gap: 12px; } .menuItemLabel { @@ -37,8 +38,22 @@ > * { min-width: 0; } + + label { + margin-left: auto; + } } +.menuItemLabelEmbedded { + white-space: normal; + gap: 6px; + height: auto; + > * { + min-width: auto; + } +} + + .svg { flex-shrink: 0; width: 1rem; diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js index 79e88fed7..ab0543386 100644 --- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js @@ -1,7 +1,6 @@ import { useLayoutEffect, useState } from 'preact/hooks'; import { Customizer, getItems } from './Customizer.js'; -import styles from './CustomizerDrawerInner.module.css'; -import { VisibilityMenu } from './VisibilityMenu.js'; +import { EmbeddedVisibilityMenu } from './VisibilityMenu.js'; import { h } from 'preact'; export function VisibilityMenuSection() { @@ -13,17 +12,12 @@ export function VisibilityMenuSection() { function handler() { setRowData(getItems()); } + window.addEventListener(Customizer.UPDATE_EVENT, handler); return () => { window.removeEventListener(Customizer.UPDATE_EVENT, handler); }; }, []); - return ( - <div class={styles.section}> - <h3 class={styles.sectionTitle}>Sections</h3> - <div class={styles.sectionBody}> - <VisibilityMenu rows={rowData} variant={'embedded'} /> - </div> - </div> - ); + + return <EmbeddedVisibilityMenu rows={rowData} />; } diff --git a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js index 0bde3dbc1..31640556f 100644 --- a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js +++ b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js @@ -34,8 +34,7 @@ export class CustomizerPage { const { page } = this.ntp; await page.locator('aside').getByRole('button', { name: 'Close' }).click(); await expect(page.locator('aside')).toHaveAttribute('aria-hidden', 'true'); - // todo: This will be added in a follow up - // await expect(page.locator('aside')).toHaveCSS('visibility', 'hidden'); + await expect(page.locator('aside')).toHaveCSS('visibility', 'hidden'); } async opensSettings() { diff --git a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js index f498d2651..f901e3792 100644 --- a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js +++ b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.spec.js @@ -203,7 +203,7 @@ test.describe('newtab customizer', () => { await cp.hasEmptyImagesPanel(); await cp.acceptsImagesUpdate(); }); - test.skip('loads without images, and handles root-level exceptions', async ({ page }, workerInfo) => { + test('loads without images, and handles root-level exceptions', async ({ page }, workerInfo) => { const ntp = NewtabPage.create(page, workerInfo); const cp = new CustomizerPage(ntp); await ntp.reducedMotion(); @@ -213,7 +213,7 @@ test.describe('newtab customizer', () => { await cp.acceptsBadImagesUpdate(); await cp.closesCustomizer(); }); - test.skip('loads with images, and handles nested exceptions', async ({ page }, workerInfo) => { + test('loads with images, and handles nested exceptions', async ({ page }, workerInfo) => { const ntp = NewtabPage.create(page, workerInfo); const cp = new CustomizerPage(ntp); await ntp.reducedMotion(); @@ -230,7 +230,7 @@ test.describe('newtab customizer', () => { await cp.opensCustomizer(); await cp.uploadsFirstImage(); }); - test.skip('trigger additional file uploads', async ({ page }, workerInfo) => { + test('trigger additional file uploads', async ({ page }, workerInfo) => { const ntp = NewtabPage.create(page, workerInfo); const cp = new CustomizerPage(ntp); await ntp.reducedMotion(); @@ -249,7 +249,7 @@ test.describe('newtab customizer', () => { await cp.setsDarkTheme(); await cp.darkThemeIsSelected(); }); - test.skip('opening settings', async ({ page }, workerInfo) => { + test('opening settings', async ({ page }, workerInfo) => { const ntp = NewtabPage.create(page, workerInfo); const cp = new CustomizerPage(ntp); await ntp.reducedMotion(); diff --git a/special-pages/pages/new-tab/app/customizer/mocks.js b/special-pages/pages/new-tab/app/customizer/mocks.js index 4100965ee..77793df1e 100644 --- a/special-pages/pages/new-tab/app/customizer/mocks.js +++ b/special-pages/pages/new-tab/app/customizer/mocks.js @@ -139,6 +139,12 @@ export function customizerData() { if (url.searchParams.has('userImages')) { customizer.userImages = [values.userImages['01'], values.userImages['02'], values.userImages['03']]; + if (url.searchParams.get('willThrowPageException') === 'userImages') { + customizer.userImages[0] = { + ...customizer.userImages[0], + id: '__will_throw__', + }; + } } if (url.searchParams.has('userColor')) { const hex = `#` + url.searchParams.get('userColor'); diff --git a/special-pages/pages/new-tab/app/customizer/strings.json b/special-pages/pages/new-tab/app/customizer/strings.json new file mode 100644 index 000000000..5338d1acc --- /dev/null +++ b/special-pages/pages/new-tab/app/customizer/strings.json @@ -0,0 +1,6 @@ +{ + "customizer_image_privacy": { + "title": "Images are stored on your device so DuckDuckGo can’t see or access them.", + "note": "Shown near a button that allows a user to upload an image to be used as a background." + } +} diff --git a/special-pages/pages/new-tab/app/favorites/components/Favorites.js b/special-pages/pages/new-tab/app/favorites/components/Favorites.js index e2e820459..76f1105a1 100644 --- a/special-pages/pages/new-tab/app/favorites/components/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/components/Favorites.js @@ -10,6 +10,8 @@ import { usePlatformName } from '../../settings.provider.js'; import { useDropzoneSafeArea } from '../../dropzone.js'; import { TileRow } from './TileRow.js'; import { FavoritesContext } from './FavoritesProvider.js'; +import { CustomizerContext } from '../../customizer/CustomizerProvider.js'; +import { useComputed } from '@preact/signals'; /** * @typedef {import('../../../types/new-tab.js').Expansion} Expansion @@ -46,9 +48,15 @@ export function Favorites({ gridRef, favorites, expansion, toggle, openContextMe const hiddenCount = expansion === 'collapsed' ? favorites.length - ROW_CAPACITY : 0; const rowHeight = ITEM_HEIGHT + ROW_GAP; const canToggleExpansion = favorites.length >= ROW_CAPACITY; + const { data } = useContext(CustomizerContext); + const kind = useComputed(() => data.value.background.kind); return ( - <div class={cn(styles.root, !canToggleExpansion && styles.noExpansionBtn)} data-testid="FavoritesConfigured"> + <div + class={cn(styles.root, !canToggleExpansion && styles.noExpansionBtn)} + data-testid="FavoritesConfigured" + data-background-kind={kind} + > <VirtualizedGridRows WIDGET_ID={WIDGET_ID} favorites={favorites} diff --git a/special-pages/pages/new-tab/app/favorites/components/Tile.js b/special-pages/pages/new-tab/app/favorites/components/Tile.js index a603c470b..516b1f8ae 100644 --- a/special-pages/pages/new-tab/app/favorites/components/Tile.js +++ b/special-pages/pages/new-tab/app/favorites/components/Tile.js @@ -139,7 +139,7 @@ function PlusIconWrapper({ onClick }) { const { state, ref } = useItemState(`PLACEHOLDER-URL-${id}`, `PLACEHOLDER-ID-${id}`); return ( <div class={styles.item} ref={ref} data-edge={'closestEdge' in state && state.closestEdge}> - <button class={cn(styles.icon, styles.placeholder, styles.plus)} aria-labelledby={id} onClick={onClick}> + <button class={cn(styles.icon, styles.plus, styles.draggable)} aria-labelledby={id} onClick={onClick}> <PlusIcon /> </button> <div class={styles.text} id={id}> diff --git a/special-pages/pages/new-tab/app/favorites/components/Tile.module.css b/special-pages/pages/new-tab/app/favorites/components/Tile.module.css index 4b343b0db..8856ce9c6 100644 --- a/special-pages/pages/new-tab/app/favorites/components/Tile.module.css +++ b/special-pages/pages/new-tab/app/favorites/components/Tile.module.css @@ -37,22 +37,41 @@ } .draggable { - background-color: var(--color-black-at-3); + backdrop-filter: blur(48px); + background: var(--ntp-surface-background-color); + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12), 0px 0px 3px 0px rgba(0, 0, 0, 0.16); &:hover { - background-color: var(--color-black-at-9); + background-color: var(--color-white-at-18); } - &:active { - transform: scale(0.95); + [data-theme="dark"] & { + &:hover { + background: rgba(0, 0, 0, 0.24); + } } - [data-theme=dark] & { - background-color: var(--color-white-at-6); + [data-background-kind="userImage"] & { + border: 1px solid var(--color-black-at-9); + background: var(--color-white-at-30); + &:hover { - background-color: var(--color-white-at-12); + background: var(--color-white-at-18); } } + + [data-theme="dark"] [data-background-kind="userImage"] & { + border: 1px solid var(--color-white-at-9); + background: var(--color-black-at-18); + + &:hover { + background: rgba(0, 0, 0, 0.24); + } + } + + &:active { + transform: scale(0.95); + } } .favicon { @@ -95,25 +114,13 @@ .plus { outline: none; - border-style: solid; + border: none; color: var(--color-black-at-90); [data-theme=dark] & { color: var(--color-white-at-85); } - &:hover { - background: var(--color-black-at-3); - - [data-theme=dark] & { - background: var(--color-white-at-9); - } - } - - &:active { - transform: scale(0.95); - } - &:focus-visible { box-shadow: var(--focus-ring); } diff --git a/special-pages/pages/new-tab/app/index.js b/special-pages/pages/new-tab/app/index.js index 8ef1a1f89..b2cfe9bcc 100644 --- a/special-pages/pages/new-tab/app/index.js +++ b/special-pages/pages/new-tab/app/index.js @@ -164,7 +164,7 @@ async function resolveEntryPoints(widgets, didCatch) { try { const loaders = widgets.map((widget) => { return ( - widgetEntryPoint(widget.id) + widgetEntryPoint(widget.id, didCatch) // eslint-disable-next-line promise/prefer-await-to-then .then((mod) => [widget.id, mod]) ); diff --git a/special-pages/pages/new-tab/app/styles/ntp-theme.css b/special-pages/pages/new-tab/app/styles/ntp-theme.css index 256b50066..62b362278 100644 --- a/special-pages/pages/new-tab/app/styles/ntp-theme.css +++ b/special-pages/pages/new-tab/app/styles/ntp-theme.css @@ -2,7 +2,7 @@ --default-light-bg: var(--color-gray-0); --default-dark-bg: var(--color-gray-85); --ntp-gap: 2rem; - --ntp-drawer-width: calc(236 * var(--px-in-rem)); + --ntp-drawer-width: calc(224 * var(--px-in-rem)); --ntp-drawer-scroll-width: 12px; --ntp-combined-width: calc(var(--ntp-drawer-width) + var(--ntp-drawer-scroll-width)); @@ -24,7 +24,7 @@ /* label small */ --small-label-font-size: 11px; --small-label-font-weight: 400; - --small-label-line-height: 11px; + --small-label-line-height: 14px; --border-radius-lg: 12px; --border-radius-md: 8px; @@ -52,7 +52,7 @@ --ntp-background-color: var(--default-dark-bg); --ntp-surface-background-color: var(--color-black-at-18); --ntp-surfaces-panel-background-color: #222222; - --ntp-surface-border-color: var(--color-white-at-9); + --ntp-surface-border-color: var(--color-white-at-12); --ntp-text-normal: var(--color-white-at-84); --ntp-text-muted: var(--color-white-at-60); --ntp-color-primary: var(--color-blue-30); diff --git a/special-pages/pages/new-tab/app/widget-list/WidgetList.js b/special-pages/pages/new-tab/app/widget-list/WidgetList.js index 52c59dd24..fdcd34fa2 100644 --- a/special-pages/pages/new-tab/app/widget-list/WidgetList.js +++ b/special-pages/pages/new-tab/app/widget-list/WidgetList.js @@ -13,16 +13,17 @@ import { Centered } from '../components/Layout.js'; function placeholderWidget(id) { return { factory: () => { - return <p>Entry point for {id} was not found. This is a bug.</p>; + return null; }, }; } /** * @param {string} id + * @param {(e: {message:string}) => void} didCatch * @return {Promise<{factory: () => import("preact").ComponentChild}>} */ -export async function widgetEntryPoint(id) { +export async function widgetEntryPoint(id, didCatch) { try { const mod = await import(`../entry-points/${id}.js`); if (typeof mod.factory !== 'function') { @@ -32,6 +33,7 @@ export async function widgetEntryPoint(id) { return mod; } catch (e) { console.error(e); + didCatch(e); return placeholderWidget(id); } } diff --git a/special-pages/pages/new-tab/public/locales/en/new-tab.json b/special-pages/pages/new-tab/public/locales/en/new-tab.json index ceb6f2022..d762d1507 100644 --- a/special-pages/pages/new-tab/public/locales/en/new-tab.json +++ b/special-pages/pages/new-tab/public/locales/en/new-tab.json @@ -200,5 +200,9 @@ "favorites_add": { "title": "Add Favorite", "note": "A button that allows a user to add a new 'favorite' bookmark to their existing list" + }, + "customizer_image_privacy": { + "title": "Images are stored on your device so DuckDuckGo can’t see or access them.", + "note": "Shown near a button that allows a user to upload an image to be used as a background." } } \ No newline at end of file diff --git a/special-pages/shared/components/Switch/Switch.js b/special-pages/shared/components/Switch/Switch.js index 2297963e2..c170f9fe1 100644 --- a/special-pages/shared/components/Switch/Switch.js +++ b/special-pages/shared/components/Switch/Switch.js @@ -10,10 +10,11 @@ import styles from './Switch.module.css'; * @param {"light" | "dark"} props.theme * @param {boolean} props.pending - Indicates if the switch is in a pending state. * @param {boolean} [props.checked=false] - Indicates if the switch is checked. + * @param {'small' | 'medium' | 'large'} [props.size] - Which size? * @param {Function} props.onChecked - Callback function to be called when the switch is checked. * @param {Function} props.onUnchecked - Callback function to be called when the switch is unchecked. */ -export function Switch({ checked = false, platformName, theme, ...props }) { +export function Switch({ checked = false, platformName, size, theme, ...props }) { const { onChecked, onUnchecked, ariaLabel, pending } = props; function change(e) { if (e.target.checked === true) { @@ -23,7 +24,7 @@ export function Switch({ checked = false, platformName, theme, ...props }) { } } return ( - <label class={styles.label} data-platform-name={platformName} data-theme={theme}> + <label class={styles.label} data-platform-name={platformName} data-theme={theme} data-size={size}> <input disabled={pending} type="checkbox" diff --git a/special-pages/shared/components/Switch/Switch.module.css b/special-pages/shared/components/Switch/Switch.module.css index 9fc8f2c1d..dbe89c980 100644 --- a/special-pages/shared/components/Switch/Switch.module.css +++ b/special-pages/shared/components/Switch/Switch.module.css @@ -47,6 +47,11 @@ --track-border-radius: 10px; } + &[data-platform-name="macos"][data-size="medium"] { + --switch-width: 32px; + --switch-height: 18px; + --switch-handle-size: 16px; + } &[data-platform-name="macos"][data-theme="dark"] { --track-bg-color: var(--color-white-at-9); --switch-handle-color-checked: #CECECE;