Skip to content

Commit

Permalink
ntp: making the customizer menu more dynamic
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane Osbourne committed Oct 29, 2024
1 parent 791f7a7 commit 169144d
Show file tree
Hide file tree
Showing 14 changed files with 118 additions and 92 deletions.
15 changes: 8 additions & 7 deletions special-pages/pages/new-tab/app/components/Examples.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,23 +124,24 @@ export const otherExamples = {
<br/>
<MaxContent>
<VisibilityMenu
toggle={noop('toggle!')}
rows={[
{
id: 'favorites',
title: 'Favorites',
icon: 'star'
icon: 'star',
toggle: noop("toggle favorites"),
visibility: "hidden",
index:0
},
{
id: 'privacyStats',
title: 'Privacy Stats',
icon: 'shield'
icon: 'shield',
toggle: noop("toggle favorites"),
visibility: "visible",
index: 1
}
]}
state={[
{ checked: true },
{ checked: false }
]}
/>
</MaxContent>
</Fragment>
Expand Down
81 changes: 43 additions & 38 deletions special-pages/pages/new-tab/app/customizer/Customizer.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,41 @@
import { h } from 'preact'
import { useContext, useEffect, useRef, useState, useCallback, useId } from 'preact/hooks'
import { WidgetConfigContext } from '../widget-list/widget-config.provider.js'
import { useEffect, useRef, useState, useCallback, useId } from 'preact/hooks'
import styles from './Customizer.module.css'
import { VisibilityMenu } from './VisibilityMenu.js'
import { CustomizeIcon } from '../components/Icons.js'
import cn from 'classnames'

/**
* @import { Widgets, WidgetConfigItem } from '../../../../types/new-tab.js'
* @import { Widgets, WidgetConfigItem, WidgetVisibility } from '../../../../types/new-tab.js'
*/

/**
* Represents the NTP customizer. For now it's just the ability to toggle sections.
*/
export function Customizer () {
const { widgetConfigItems, toggle } = useContext(WidgetConfigContext)
const { setIsOpen, buttonRef, dropdownRef, isOpen } = useDropdown()
const [rowData, setRowData] = useState(/** @type {VisibilityRowData[]} */([]))

/**
* Dispatch an event every time the customizer is opened - this
* allows widgets to register themselves and provide
* allows widgets to register themselves and provide titles/icons etc.
*/
const toggleMenu = useCallback(() => {
if (isOpen) return setIsOpen(false)
/** @type {VisibilityRowData[]} */
const next = []
const detail = {
register: (/** @type {VisibilityRowData} */incoming) => {
next.push(structuredClone(incoming))
}
}
const event = new CustomEvent(Customizer.OPEN_EVENT, { detail })
window.dispatchEvent(event)
setRowData(next)
setRowData(getItems())
setIsOpen(true)
}, [isOpen])

/**
* Compute the current state of each registered row
*/
const visibilityState = rowData.map(row => {
const item = widgetConfigItems.find(w => w.id === row.id)
if (!item) console.warn('could not find', row.id)
return {
checked: item?.visibility === 'visible'
useEffect(() => {
if (!isOpen) return
function handler () {
setRowData(getItems())
}
window.addEventListener(Customizer.UPDATE_EVENT, handler)
return () => {
window.removeEventListener(Customizer.UPDATE_EVENT, handler)
}
})
}, [isOpen])

const MENU_ID = useId()
const BUTTON_ID = useId()
Expand All @@ -65,17 +54,28 @@ export function Customizer () {
class={cn(styles.dropdownMenu, { [styles.show]: isOpen })}
aria-labelledby={BUTTON_ID}
>
<VisibilityMenu
rows={rowData}
state={visibilityState}
toggle={toggle}
/>
<VisibilityMenu rows={rowData} />
</div>
</div>
)
}

Customizer.OPEN_EVENT = 'ntp-customizer-open'
Customizer.UPDATE_EVENT = 'ntp-customizer-update'

function getItems () {
/** @type {VisibilityRowData[]} */
const next = []
const detail = {
register: (/** @type {VisibilityRowData} */incoming) => {
next.push(incoming)
}
}
const event = new CustomEvent(Customizer.OPEN_EVENT, { detail })
window.dispatchEvent(event)
next.sort((a, b) => a.index - b.index)
return next
}

/**
* @param {object} props
Expand Down Expand Up @@ -154,7 +154,6 @@ function useDropdown () {

export class VisibilityRowState {
checked

/**
* @param {object} params
* @param {boolean} params.checked - whether this item should appear 'checked'
Expand All @@ -165,33 +164,39 @@ export class VisibilityRowState {
}

export class VisibilityRowData {
id
title
icon

/**
* @param {object} params
* @param {string} params.id - a unique id
* @param {string} params.title - the title as it should appear in the menu
* @param {'shield' | 'star'} params.icon - known icon name, maps to an SVG
* @param {(id: string) => void} params.toggle - toggle function for this item
* @param {number} params.index - position in the menu
* @param {WidgetVisibility} params.visibility - known icon name, maps to an SVG
*/
constructor ({ id, title, icon }) {
constructor ({ id, title, icon, toggle, visibility, index }) {
this.id = id
this.title = title
this.icon = icon
this.toggle = toggle
this.index = index
this.visibility = visibility
}
}

/**
* Call this to opt-in to the visibility menu
* @param {VisibilityRowData} row
*/
export function useCustomizer ({ title, id, icon }) {
export function useCustomizer ({ title, id, icon, toggle, visibility, index }) {
useEffect(() => {
const handler = (/** @type {CustomEvent<any>} */e) => {
e.detail.register({ title, id, icon })
e.detail.register({ title, id, icon, toggle, visibility, index })
}
window.addEventListener(Customizer.OPEN_EVENT, handler)
return () => window.removeEventListener(Customizer.OPEN_EVENT, handler)
}, [title, id, icon])
}, [title, id, icon, toggle, visibility, index])

useEffect(() => {
window.dispatchEvent(new Event(Customizer.UPDATE_EVENT))
}, [visibility])
}
13 changes: 5 additions & 8 deletions special-pages/pages/new-tab/app/customizer/VisibilityMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,29 @@ import { useTypedTranslation } from '../types.js'
* meta data like translated titles
*
* @param {object} props
* @param {(id: string) => void} props.toggle
* @param {VisibilityRowData[]} props.rows
* @param {VisibilityRowState[]} props.state
*/
export function VisibilityMenu ({ rows, state, toggle }) {
export function VisibilityMenu ({ rows }) {
const { t } = useTypedTranslation()
const MENU_ID = useId()

return (
<div className={styles.dropdownInner}>
<h2 className="sr-only">{t('widgets_visibility_menu_title')}</h2>
<ul className={styles.list}>
{rows.map((row, index) => {
const current = state[index]
{rows.map((row) => {
return (
<li key={row.id}>
<label className={styles.menuItemLabel} htmlFor={MENU_ID + row.id}>
<input
type="checkbox"
checked={current.checked}
onChange={() => toggle(row.id)}
checked={row.visibility === 'visible'}
onChange={() => row.toggle?.(row.id)}
id={MENU_ID + row.id}
class={styles.checkbox}
/>
<span aria-hidden={true} className={styles.checkboxIcon}>
{current.checked && (
{row.visibility === 'visible' && (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 9L6 11.5L12.5 5"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
.dropdownInner {
background: var(--ntp-surface-background-color);
padding: var(--sp-1);
border-radius: 8px;
backdrop-filter: blur(48px);

border: 1px solid var(--color-black-at-9);
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.20), 0px 2px 4px 0px rgba(0, 0, 0, 0.15);

@media screen and (prefers-color-scheme: dark) {
border-color: var(--color-white-at-9);
box-shadow: 0px 0px 0px 1px rgba(255, 255, 255, 0.09) inset, 0px 0px 0px 1px rgba(0, 0, 0, 0.50), 0px 2px 4px 0px rgba(0, 0, 0, 0.15), 0px 8px 16px 0px rgba(0, 0, 0, 0.40);
}
box-shadow: 0 2px 6px rgba(0, 0, 0, .1), 0 8px 16px rgba(0, 0, 0, .08);
padding: var(--sp-1);
border-radius: 8px;
}

.list {
Expand Down
4 changes: 2 additions & 2 deletions special-pages/pages/new-tab/app/favorites/Favorites.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { useCustomizer } from '../customizer/Customizer.js'

export function FavoritesCustomized () {
const { t } = useTypedTranslation()
const { id, visibility } = useVisibility()
const { id, visibility, toggle, index } = useVisibility()

// register with the visibility menu
const title = t('favorites_menu_title')
useCustomizer({ title, id, icon: 'shield' })
useCustomizer({ title, id, icon: 'star', toggle, visibility, index })

if (visibility === 'hidden') {
return null
Expand Down
19 changes: 12 additions & 7 deletions special-pages/pages/new-tab/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ export async function init (messaging, baseEnvironment) {
.withTextLength(baseEnvironment.urlParams.get('textLength'))
.withDisplay(baseEnvironment.urlParams.get('display'))

console.log('environment:', environment)
console.log('locale:', environment.locale)

const strings = environment.locale === 'en'
? enStrings
: await fetch(`./locales/${environment.locale}/new-tab.json`)
Expand All @@ -54,6 +51,10 @@ export async function init (messaging, baseEnvironment) {
.withPlatformName(init.platform?.name)
.withPlatformName(baseEnvironment.urlParams.get('platform'))

console.log('environment:', environment)
console.log('settings:', settings)
console.log('locale:', environment.locale)

const didCatch = (error) => {
const message = error?.message || error?.error || 'unknown'
messaging.reportPageException({ message })
Expand All @@ -71,9 +72,11 @@ export async function init (messaging, baseEnvironment) {
debugState={environment.debugState}
injectName={environment.injectName}
willThrow={environment.willThrow}>
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
<Components />
</TranslationProvider>
<SettingsProvider settings={settings}>
<TranslationProvider translationObject={strings} fallback={strings} textLength={environment.textLength}>
<Components />
</TranslationProvider>
</SettingsProvider>
</EnvironmentProvider>
, root)
}
Expand All @@ -82,7 +85,9 @@ export async function init (messaging, baseEnvironment) {
<EnvironmentProvider
debugState={environment.debugState}
injectName={environment.injectName}
willThrow={environment.willThrow}>
willThrow={environment.willThrow}
env={environment.env}
>
<ErrorBoundary didCatch={didCatch} fallback={<Fallback showDetails={environment.env === 'development'}/>}>
<UpdateEnvironment search={window.location.search}/>
<MessagingContext.Provider value={messaging}>
Expand Down
4 changes: 2 additions & 2 deletions special-pages/pages/new-tab/app/privacy-stats/PrivacyStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ export function Body ({ trackerCompanies, listAttrs = {} }) {
*/
export function PrivacyStatsCustomized () {
const { t } = useTypedTranslation()
const { visibility, id } = useVisibility()
const { visibility, id, toggle, index } = useVisibility()

const title = t('trackerStatsMenuTitle')
useCustomizer({ title, id, icon: 'star' })
useCustomizer({ title, id, icon: 'shield', toggle, visibility, index })

if (visibility === 'hidden') {
return null
Expand Down
2 changes: 1 addition & 1 deletion special-pages/pages/new-tab/app/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export class Service {
// some services will not implement persistence
if (!this.impl.persist) return

// if the data never read, there's nothing to persist
// if the data was never set, there's nothing to persist
if (this.data === null) return

// send the data
Expand Down
3 changes: 2 additions & 1 deletion special-pages/pages/new-tab/app/widget-list/WidgetList.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function WidgetList () {

return (
<Stack gap={'var(--sp-8)'}>
{widgets.map((widget) => {
{widgets.map((widget, index) => {
const matchingConfig = widgetConfigItems.find(item => item.id === widget.id)
if (!matchingConfig) {
const matching = widgetMap[widget.id]
Expand All @@ -49,6 +49,7 @@ export function WidgetList () {
<WidgetVisibilityProvider
visibility={matchingConfig.visibility}
id={matchingConfig.id}
index={index}
>
{widgetMap[widget.id]?.()}
</WidgetVisibilityProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ const WidgetVisibilityContext = createContext({
id: /** @type {WidgetConfigItem['id']} */(''),
/** @type {(id: string) => void} */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toggle: (_id) => {}
toggle: (_id) => {},
/** @type {number} */
index: -1
})

export function useVisibility () {
Expand All @@ -81,6 +83,7 @@ export function useVisibility () {
* @param {object} props
* @param {WidgetConfigItem['id']} props.id - the current id key used for storage
* @param {WidgetConfigItem['visibility']} props.visibility - the current id key used for storage
* @param {number} props.index - the current id key used for storage
* @param {import("preact").ComponentChild} props.children
*/
export function WidgetVisibilityProvider (props) {
Expand All @@ -89,10 +92,9 @@ export function WidgetVisibilityProvider (props) {
return <WidgetVisibilityContext.Provider value={{
visibility: props.visibility,
id: props.id,
toggle
toggle,
index: props.index
}}>
<div style={{ viewTransitionName: `widget-${props.id}` }}>
{props.children}
</div>
{props.children}
</WidgetVisibilityContext.Provider>
}
Loading

0 comments on commit 169144d

Please sign in to comment.