Skip to content

Commit e50190e

Browse files
shakyShaneShane Osbourne
andauthored
ntp: customizer button (#1152)
* ntp: customizer button * fix tests * design feedback + translations * updated tests --------- Co-authored-by: Shane Osbourne <[email protected]>
1 parent 6e320dc commit e50190e

File tree

15 files changed

+530
-51
lines changed

15 files changed

+530
-51
lines changed

special-pages/pages/new-tab/app/components/Examples.jsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { h } from "preact";
1+
import { Fragment, h } from "preact";
22
import { PrivacyStatsMockProvider } from "../privacy-stats/mocks/PrivacyStatsMockProvider.js";
33
import { Body, Heading, PrivacyStatsConsumer } from "../privacy-stats/PrivacyStats.js";
44
import { stats } from "../privacy-stats/mocks/stats.js";
55
import { noop } from "../utils.js";
6+
import { VisibilityMenu } from "../customizer/VisibilityMenu.js";
7+
import { CustomizerButton } from "../customizer/Customizer.js";
68

79
/** @type {Record<string, {factory: () => import("preact").ComponentChild}>} */
810
export const mainExamples = {
@@ -51,7 +53,7 @@ export const otherExamples = {
5153
ticker={true}
5254
config={{
5355
expansion: "expanded",
54-
animation: { kind: "none" }
56+
animation: {kind: "none"}
5557
}}
5658
><PrivacyStatsConsumer/></PrivacyStatsMockProvider>
5759
},
@@ -60,8 +62,47 @@ export const otherExamples = {
6062
ticker={true}
6163
config={{
6264
expansion: "expanded",
63-
animation: { kind: "view-transitions" }
65+
animation: {kind: "view-transitions"}
6466
}}
6567
><PrivacyStatsConsumer/></PrivacyStatsMockProvider>
6668
},
69+
'customizer-menu': {
70+
factory: () => (
71+
<Fragment>
72+
<div>
73+
<CustomizerButton isOpen={true}/>
74+
</div>
75+
<br/>
76+
<MaxContent>
77+
<VisibilityMenu
78+
toggle={noop('toggle!')}
79+
rows={[
80+
{
81+
id: 'favorites',
82+
title: 'Favorites',
83+
icon: 'star'
84+
},
85+
{
86+
id: 'privacyStats',
87+
title: 'Privacy Stats',
88+
icon: 'shield'
89+
}
90+
]}
91+
state={[
92+
{checked: true},
93+
{checked: false},
94+
]}
95+
/>
96+
</MaxContent>
97+
</Fragment>
98+
)
99+
},
100+
}
101+
102+
function MaxContent({children}) {
103+
return (
104+
<div style={{display: 'grid', gridTemplateColumns: 'max-content'}}>
105+
{children}
106+
</div>
107+
)
67108
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,41 @@ export function ChevronButton () {
1111
</svg>
1212
)
1313
}
14+
15+
export function CustomizeIcon () {
16+
return (
17+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class={styles.customize}>
18+
<path fill-rule="evenodd" clip-rule="evenodd"
19+
d="M4.5 1C2.567 1 1 2.567 1 4.5C1 6.433 2.567 8 4.5 8C6.17556 8 7.57612 6.82259 7.91946 5.25H14.375C14.7202 5.25 15 4.97018 15 4.625C15 4.27982 14.7202 4 14.375 4H7.96456C7.72194 2.30385 6.26324 1 4.5 1ZM2.25 4.5C2.25 3.25736 3.25736 2.25 4.5 2.25C5.74264 2.25 6.75 3.25736 6.75 4.5C6.75 5.74264 5.74264 6.75 4.5 6.75C3.25736 6.75 2.25 5.74264 2.25 4.5Z"
20+
fill="currentColor"
21+
/>
22+
<path fill-rule="evenodd" clip-rule="evenodd"
23+
d="M8.03544 12H1.625C1.27982 12 1 11.7202 1 11.375C1 11.0298 1.27982 10.75 1.625 10.75H8.08054C8.42388 9.17741 9.82444 8 11.5 8C13.433 8 15 9.567 15 11.5C15 13.433 13.433 15 11.5 15C9.73676 15 8.27806 13.6961 8.03544 12ZM9.25 11.5C9.25 10.2574 10.2574 9.25 11.5 9.25C12.7426 9.25 13.75 10.2574 13.75 11.5C13.75 12.7426 12.7426 13.75 11.5 13.75C10.2574 13.75 9.25 12.7426 9.25 11.5Z"
24+
fill="currentColor"
25+
/>
26+
</svg>
27+
28+
)
29+
}
30+
31+
export function DuckFoot () {
32+
return (
33+
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16">
34+
<path clip-rule="evenodd"
35+
fill="currentColor"
36+
d="M6.483.612A2.13 2.13 0 0 1 7.998 0c.56.001 1.115.215 1.512.62.673.685 1.26 1.045 1.852 1.228.594.185 1.31.228 2.311.1a2.175 2.175 0 0 1 1.575.406c.452.34.746.862.75 1.445.033 3.782-.518 6.251-1.714 8.04-1.259 1.882-3.132 2.831-5.045 3.8l-.123.063-.003.001-.125.063a2.206 2.206 0 0 1-1.976 0l-.124-.063-.003-.001-.124-.063c-1.913-.969-3.786-1.918-5.045-3.8C.52 10.05-.031 7.58 0 3.798a1.83 1.83 0 0 1 .75-1.444 2.175 2.175 0 0 1 1.573-.407c1.007.127 1.725.076 2.32-.114.59-.189 1.172-.551 1.839-1.222Zm2.267 1.36v12.233c1.872-.952 3.311-1.741 4.287-3.2.949-1.42 1.493-3.529 1.462-7.194 0-.072-.037-.17-.152-.257a.677.677 0 0 0-.484-.118c-1.126.144-2.075.115-2.945-.155-.77-.239-1.47-.664-2.168-1.309Zm-1.5 12.233V1.955c-.69.635-1.383 1.063-2.15 1.308-.87.278-1.823.317-2.963.174a.677.677 0 0 0-.484.117c-.115.087-.151.186-.152.258-.03 3.664.513 5.774 1.462 7.192.976 1.46 2.415 2.249 4.287 3.201Z">
37+
</path>
38+
</svg>
39+
)
40+
}
41+
42+
export function Shield () {
43+
return (
44+
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
45+
<path fill-rule="evenodd" clip-rule="evenodd"
46+
d="M6.341 1.367c.679-1.375 2.64-1.375 3.318 0l1.366 2.767a.35.35 0 0 0 .264.192l3.054.444c1.517.22 2.123 2.085 1.025 3.155l-2.21 2.155a.35.35 0 0 0-.1.31l.521 3.041c.26 1.512-1.327 2.664-2.684 1.95l-2.732-1.436a.35.35 0 0 0-.326 0l-2.732 1.437c-1.357.713-2.943-.44-2.684-1.95l.522-3.043a.35.35 0 0 0-.1-.31L.631 7.926C-.466 6.855.14 4.99 1.657 4.77l3.055-.444a.35.35 0 0 0 .263-.192l1.366-2.767Zm1.973.664a.35.35 0 0 0-.628 0L6.32 4.798A1.85 1.85 0 0 1 4.927 5.81l-3.054.444a.35.35 0 0 0-.194.597l2.21 2.154a1.85 1.85 0 0 1 .532 1.638L3.9 13.685a.35.35 0 0 0 .508.369l2.732-1.436a1.85 1.85 0 0 1 1.722 0l2.732 1.436a.35.35 0 0 0 .508-.369l-.522-3.042a1.85 1.85 0 0 1 .532-1.638l2.21-2.154a.35.35 0 0 0-.194-.597l-3.054-.444A1.85 1.85 0 0 1 9.68 4.798L8.314 2.031Z"
47+
fill="currentColor"
48+
></path>
49+
</svg>
50+
)
51+
}

special-pages/pages/new-tab/app/components/Icons.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@
2121
fill-opacity: 0.5
2222
}
2323
}
24+
25+
.customize {
26+
27+
}
28+
Lines changed: 188 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,197 @@
11
import { h } from 'preact'
2-
import { useContext } from 'preact/hooks'
2+
import { useContext, useEffect, useRef, useState, useCallback, useId } from 'preact/hooks'
33
import { WidgetConfigContext } from '../widget-list/widget-config.provider.js'
44
import styles from './Customizer.module.css'
5+
import { VisibilityMenu } from './VisibilityMenu.js'
6+
import { CustomizeIcon } from '../components/Icons.js'
7+
import cn from 'classnames'
58

9+
/**
10+
* @import { Widgets, WidgetConfigItem } from '../../../../types/new-tab.js'
11+
*/
12+
13+
/**
14+
* Represents the NTP customizer. For now it's just the ability to toggle sections.
15+
*/
616
export function Customizer () {
7-
const { widgets, widgetConfigItems, toggle } = useContext(WidgetConfigContext)
17+
const { widgetConfigItems, toggle } = useContext(WidgetConfigContext)
18+
const { setIsOpen, buttonRef, dropdownRef, isOpen } = useDropdown()
19+
const [rowData, setRowData] = useState(/** @type {VisibilityRowData[]} */([]))
20+
21+
/**
22+
* Dispatch an event every time the customizer is opened - this
23+
* allows widgets to register themselves and provide
24+
*/
25+
const toggleMenu = useCallback(() => {
26+
if (isOpen) return setIsOpen(false)
27+
/** @type {VisibilityRowData[]} */
28+
const next = []
29+
const detail = {
30+
register: (/** @type {VisibilityRowData} */incoming) => {
31+
next.push(structuredClone(incoming))
32+
}
33+
}
34+
const event = new CustomEvent(Customizer.OPEN_EVENT, { detail })
35+
window.dispatchEvent(event)
36+
setRowData(next)
37+
setIsOpen(true)
38+
}, [isOpen])
39+
40+
/**
41+
* Compute the current state of each registered row
42+
*/
43+
const visibilityState = rowData.map(row => {
44+
const item = widgetConfigItems.find(w => w.id === row.id)
45+
if (!item) console.warn('could not find', row.id)
46+
return {
47+
checked: item?.visibility === 'visible'
48+
}
49+
})
50+
51+
const MENU_ID = useId()
52+
const BUTTON_ID = useId()
53+
54+
return (
55+
<div class={styles.root} ref={dropdownRef}>
56+
<CustomizerButton
57+
buttonId={BUTTON_ID}
58+
menuId={MENU_ID}
59+
toggleMenu={toggleMenu}
60+
buttonRef={buttonRef}
61+
isOpen={isOpen}
62+
/>
63+
<div
64+
id={MENU_ID}
65+
class={cn(styles.dropdownMenu, { [styles.show]: isOpen })}
66+
aria-labelledby={BUTTON_ID}
67+
>
68+
<VisibilityMenu
69+
rows={rowData}
70+
state={visibilityState}
71+
toggle={toggle}
72+
/>
73+
</div>
74+
</div>
75+
)
76+
}
77+
78+
Customizer.OPEN_EVENT = 'ntp-customizer-open'
79+
80+
/**
81+
* @param {object} props
82+
* @param {string} [props.menuId]
83+
* @param {string} [props.buttonId]
84+
* @param {boolean} props.isOpen
85+
* @param {() => void} [props.toggleMenu]
86+
* @param {import("preact").Ref<HTMLButtonElement>} [props.buttonRef]
87+
*/
88+
export function CustomizerButton ({ menuId, buttonId, isOpen, toggleMenu, buttonRef }) {
89+
return (
90+
<button
91+
ref={buttonRef}
92+
className={styles.customizeButton}
93+
onClick={toggleMenu}
94+
aria-haspopup="true"
95+
aria-expanded={isOpen}
96+
aria-controls={menuId}
97+
id={buttonId}
98+
>
99+
<CustomizeIcon/>
100+
<span>Customize</span>
101+
</button>
102+
)
103+
}
104+
105+
export function CustomizerMenuPositionedFixed ({ children }) {
8106
return (
9-
<div class={styles.root}>
10-
<ul class={styles.list}>
11-
{widgets.map((widget) => {
12-
const matchingConfig = widgetConfigItems.find(item => item.id === widget.id)
13-
if (!matchingConfig) {
14-
console.warn('missing config for widget: ', widget)
15-
return null
16-
}
17-
return (
18-
<li key={widget.id} class={styles.item}>
19-
<label class={styles.label}>
20-
<input
21-
type="checkbox"
22-
checked={matchingConfig.visibility === 'visible'}
23-
onChange={() => toggle(widget.id)}
24-
value={widget.id}
25-
/>
26-
<span>
27-
{widget.id}
28-
</span>
29-
</label>
30-
</li>
31-
)
32-
})}
33-
</ul>
107+
<div class={styles.lowerRightFixed}>
108+
{children}
34109
</div>
35110
)
36111
}
112+
113+
function useDropdown () {
114+
/** @type {import("preact").Ref<HTMLDivElement>} */
115+
const dropdownRef = useRef(null)
116+
/** @type {import("preact").Ref<HTMLButtonElement>} */
117+
const buttonRef = useRef(null)
118+
119+
const [isOpen, setIsOpen] = useState(false)
120+
121+
/**
122+
* Event handlers when it's open
123+
*/
124+
useEffect(() => {
125+
if (!isOpen) return
126+
const handleFocusOutside = (event) => {
127+
if (dropdownRef.current && !dropdownRef.current.contains(event.target) && !buttonRef.current?.contains(event.target)) {
128+
setIsOpen(false)
129+
}
130+
}
131+
const handleClickOutside = (event) => {
132+
if (dropdownRef.current && !dropdownRef.current.contains?.(event.target)) {
133+
setIsOpen(false)
134+
}
135+
}
136+
const handleKeyDown = (event) => {
137+
if (event.key === 'Escape') {
138+
setIsOpen(false)
139+
buttonRef.current?.focus?.()
140+
}
141+
}
142+
document.addEventListener('mousedown', handleClickOutside)
143+
document.addEventListener('keydown', handleKeyDown)
144+
document.addEventListener('focusin', handleFocusOutside)
145+
return () => {
146+
document.removeEventListener('mousedown', handleClickOutside)
147+
document.removeEventListener('keydown', handleKeyDown)
148+
document.removeEventListener('focusin', handleFocusOutside)
149+
}
150+
}, [isOpen])
151+
152+
return { dropdownRef, buttonRef, isOpen, setIsOpen }
153+
}
154+
155+
export class VisibilityRowState {
156+
checked
157+
158+
/**
159+
* @param {object} params
160+
* @param {boolean} params.checked - whether this item should appear 'checked'
161+
*/
162+
constructor ({ checked }) {
163+
this.checked = checked
164+
}
165+
}
166+
167+
export class VisibilityRowData {
168+
id
169+
title
170+
icon
171+
172+
/**
173+
* @param {object} params
174+
* @param {string} params.id - a unique id
175+
* @param {string} params.title - the title as it should appear in the menu
176+
* @param {'shield' | 'star'} params.icon - known icon name, maps to an SVG
177+
*/
178+
constructor ({ id, title, icon }) {
179+
this.id = id
180+
this.title = title
181+
this.icon = icon
182+
}
183+
}
184+
185+
/**
186+
* Call this to opt-in to the visibility menu
187+
* @param {VisibilityRowData} row
188+
*/
189+
export function useCustomizer ({ title, id, icon }) {
190+
useEffect(() => {
191+
const handler = (/** @type {CustomEvent<any>} */e) => {
192+
e.detail.register({ title, id, icon })
193+
}
194+
window.addEventListener(Customizer.OPEN_EVENT, handler)
195+
return () => window.removeEventListener(Customizer.OPEN_EVENT, handler)
196+
}, [title, id, icon])
197+
}

0 commit comments

Comments
 (0)