|
1 | 1 | import { h } from 'preact' |
2 | | -import { useContext } from 'preact/hooks' |
| 2 | +import { useContext, useEffect, useRef, useState, useCallback, useId } from 'preact/hooks' |
3 | 3 | import { WidgetConfigContext } from '../widget-list/widget-config.provider.js' |
4 | 4 | import styles from './Customizer.module.css' |
| 5 | +import { VisibilityMenu } from './VisibilityMenu.js' |
| 6 | +import { CustomizeIcon } from '../components/Icons.js' |
| 7 | +import cn from 'classnames' |
5 | 8 |
|
| 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 | + */ |
6 | 16 | 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 }) { |
8 | 106 | 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} |
34 | 109 | </div> |
35 | 110 | ) |
36 | 111 | } |
| 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