Skip to content

Commit caeede3

Browse files
shakyShaneShane Osbourne
andauthored
ntp: favorites components (#1208)
* ntp: favorites components * linting * favorites is not configurable yet. --------- Co-authored-by: Shane Osbourne <[email protected]>
1 parent 5579d2f commit caeede3

File tree

13 files changed

+885
-37
lines changed

13 files changed

+885
-37
lines changed

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

Lines changed: 0 additions & 9 deletions
This file was deleted.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { noop } from '../utils.js';
77
import { VisibilityMenu } from '../customizer/VisibilityMenu.js';
88
import { CustomizerButton } from '../customizer/Customizer.js';
99
import { rmfDataExamples } from '../remote-messaging-framework/mocks/rmf.data.js';
10+
import { favoritesExamples } from '../favorites/components/FavoritesExamples.js';
1011

1112
/** @type {Record<string, {factory: () => import("preact").ComponentChild}>} */
1213
export const mainExamples = {
@@ -89,6 +90,7 @@ export const mainExamples = {
8990
/>
9091
),
9192
},
93+
...favoritesExamples,
9294
};
9395

9496
export const otherExamples = {
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { h } from 'preact';
2-
import { FavoritesCustomized } from '../favorites/Favorites.js';
32
import { Centered } from '../components/Layout.js';
43

54
export function factory() {
65
return (
76
<Centered>
8-
<FavoritesCustomized />
7+
<p>Favorites coming soon...</p>
98
</Centered>
109
);
1110
}

special-pages/pages/new-tab/app/favorites/Favorites.js

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Constant array of colors for empty favicon backgrounds
2+
const EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES = [
3+
'#94B3AF',
4+
'#727998',
5+
'#645468',
6+
'#4D5F7F',
7+
'#855DB6',
8+
'#5E5ADB',
9+
'#678FFF',
10+
'#6BB4EF',
11+
'#4A9BAE',
12+
'#66C4C6',
13+
'#55D388',
14+
'#99DB7A',
15+
'#ECCC7B',
16+
'#E7A538',
17+
'#DD6B4C',
18+
'#D65D62',
19+
];
20+
21+
/**
22+
* Converts a URL to a color from the predefined array
23+
* @param {string} url
24+
*/
25+
export function urlToColor(url) {
26+
const host = getHost(url);
27+
const index = Math.abs(getDJBHash(host) % EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES.length);
28+
return EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES[index];
29+
}
30+
31+
/**
32+
* DJB hashing algorithm to get a consistent color index from URL host
33+
* @param {string} str
34+
*/
35+
function getDJBHash(str) {
36+
let hash = 5381;
37+
for (let i = 0; i < str.length; i++) {
38+
hash = (hash << 5) + hash + str.charCodeAt(i);
39+
}
40+
return hash;
41+
}
42+
43+
/**
44+
* Extracts the host part of the URL
45+
* @param {string} url
46+
* @return {string}
47+
*/
48+
function getHost(url) {
49+
try {
50+
const urlObj = new URL(url);
51+
return urlObj.hostname.replace(/^www\./, '');
52+
} catch (e) {
53+
return '?';
54+
}
55+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { h } from 'preact';
2+
import { useId, useMemo } from 'preact/hooks';
3+
import { memo } from 'preact/compat';
4+
import cn from 'classnames';
5+
6+
import styles from './Favorites.module.css';
7+
import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js';
8+
import { ShowHideButton } from '../../components/ShowHideButton.jsx';
9+
import { useTypedTranslation } from '../../types.js';
10+
import { usePlatformName } from '../../settings.provider.js';
11+
12+
/**
13+
* @typedef {import('../../../../../types/new-tab.js').Expansion} Expansion
14+
* @typedef {import('../../../../../types/new-tab.js').Favorite} Favorite
15+
* @typedef {import('../../../../../types/new-tab.js').FavoritesOpenAction['target']} OpenTarget
16+
*/
17+
export const FavoritesMemo = memo(Favorites);
18+
19+
/**
20+
* Favorites Grid.
21+
*
22+
* @param {object} props
23+
* @param {import("preact").Ref<any>} [props.gridRef]
24+
* @param {Favorite[]} props.favorites
25+
* @param {Expansion} props.expansion
26+
* @param {() => void} props.toggle
27+
* @param {(id: string) => void} props.openContextMenu
28+
* @param {(id: string, target: OpenTarget) => void} props.openFavorite
29+
* @param {() => void} props.add
30+
*/
31+
export function Favorites({ gridRef, favorites, expansion, toggle, openContextMenu, openFavorite, add }) {
32+
const platformName = usePlatformName();
33+
const { t } = useTypedTranslation();
34+
35+
const ROW_CAPACITY = 6;
36+
37+
// see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/
38+
const WIDGET_ID = useId();
39+
const TOGGLE_ID = useId();
40+
41+
const ITEM_PREFIX = useId();
42+
const placeholders = calculatePlaceholders(favorites.length, ROW_CAPACITY);
43+
const hiddenCount = expansion === 'collapsed' ? favorites.length - ROW_CAPACITY : 0;
44+
45+
// only recompute the list
46+
const items = useMemo(() => {
47+
return favorites
48+
.map((item, index) => {
49+
return (
50+
<TileMemo
51+
url={item.url}
52+
faviconSrc={item.favicon?.src}
53+
faviconMax={item.favicon?.maxAvailableSize}
54+
title={item.title}
55+
key={item.id + item.favicon?.src + item.favicon?.maxAvailableSize}
56+
id={item.id}
57+
index={index}
58+
/>
59+
);
60+
})
61+
.concat(
62+
Array.from({ length: placeholders }).map((_, index) => {
63+
if (index === 0) {
64+
return <PlusIconMemo key="placeholder-plus" onClick={add} />;
65+
}
66+
return <Placeholder key={`placeholder-${index}`} />;
67+
}),
68+
);
69+
}, [favorites, placeholders, ITEM_PREFIX, add]);
70+
71+
/**
72+
* @param {MouseEvent} event
73+
*/
74+
function onContextMenu(event) {
75+
let target = /** @type {HTMLElement|null} */ (event.target);
76+
while (target && target !== event.currentTarget) {
77+
if (typeof target.dataset.id === 'string') {
78+
event.preventDefault();
79+
event.stopImmediatePropagation();
80+
return openContextMenu(target.dataset.id);
81+
} else {
82+
target = target.parentElement;
83+
}
84+
}
85+
}
86+
/**
87+
* @param {MouseEvent} event
88+
*/
89+
function onClick(event) {
90+
let target = /** @type {HTMLElement|null} */ (event.target);
91+
while (target && target !== event.currentTarget) {
92+
if (typeof target.dataset.id === 'string') {
93+
event.preventDefault();
94+
event.stopImmediatePropagation();
95+
const isControlClick = platformName === 'macos' ? event.metaKey : event.ctrlKey;
96+
if (isControlClick) {
97+
return openFavorite(target.dataset.id, 'new-tab');
98+
} else if (event.shiftKey) {
99+
return openFavorite(target.dataset.id, 'new-window');
100+
}
101+
return openFavorite(target.dataset.id, 'same-tab');
102+
} else {
103+
target = target.parentElement;
104+
}
105+
}
106+
}
107+
108+
const canToggleExpansion = items.length > ROW_CAPACITY;
109+
110+
return (
111+
<div class={styles.root} data-testid="FavoritesConfigured">
112+
<div class={styles.grid} id={WIDGET_ID} ref={gridRef} onContextMenu={onContextMenu} onClick={onClick}>
113+
{items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)}
114+
</div>
115+
<div
116+
className={cn({
117+
[styles.showhide]: true,
118+
[styles.showhideVisible]: canToggleExpansion,
119+
})}
120+
>
121+
{canToggleExpansion && (
122+
<ShowHideButton
123+
buttonAttrs={{
124+
'aria-expanded': expansion === 'expanded',
125+
'aria-pressed': expansion === 'expanded',
126+
'aria-controls': WIDGET_ID,
127+
id: TOGGLE_ID,
128+
}}
129+
text={
130+
expansion === 'expanded' ? t('favorites_show_less') : t('favorites_show_more', { count: String(hiddenCount) })
131+
}
132+
onClick={toggle}
133+
/>
134+
)}
135+
</div>
136+
</div>
137+
);
138+
}
139+
140+
/**
141+
* @param {number} totalItems
142+
* @param {number} itemsPerRow
143+
* @return {number|number}
144+
*/
145+
function calculatePlaceholders(totalItems, itemsPerRow) {
146+
if (totalItems === 0) return itemsPerRow;
147+
if (totalItems === itemsPerRow) return 1;
148+
// Calculate how many items are left over in the last row
149+
const itemsInLastRow = totalItems % itemsPerRow;
150+
151+
// If there are leftover items, calculate the placeholders needed to fill the last row
152+
const placeholders = itemsInLastRow > 0 ? itemsPerRow - itemsInLastRow : 1;
153+
154+
return placeholders;
155+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
.root {
2+
width: 100%;
3+
margin: 0 auto;
4+
display: grid;
5+
grid-template-rows: auto auto;
6+
grid-template-areas:
7+
'grid'
8+
'showhide';
9+
}
10+
11+
.showhide {
12+
grid-area: showhide;
13+
height: 32px;
14+
display: grid;
15+
justify-items: center;
16+
}
17+
18+
.root {
19+
&:hover {
20+
.showhideVisible [aria-controls] {
21+
opacity: 1;
22+
}
23+
}
24+
&:focus-within {
25+
.showhideVisible [aria-controls] {
26+
opacity: 1;
27+
}
28+
}
29+
}
30+
31+
.hr {
32+
grid-area: middle;
33+
width: 100%;
34+
height: 1px;
35+
border-color: var(--color-black-at-9);
36+
@media screen and (prefers-color-scheme: dark) {
37+
border-color: var(--color-white-at-9);
38+
}
39+
}
40+
41+
.grid {
42+
grid-area: grid;
43+
}
44+
45+
.grid {
46+
display: grid;
47+
grid-template-columns: repeat(6, 1fr);
48+
align-items: start;
49+
grid-row-gap: 8px;
50+
margin-left: -12px;
51+
margin-right: -12px;
52+
}
53+
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { h } from 'preact';
2+
3+
import { noop } from '../../utils.js';
4+
import { favorites } from '../mocks/favorites.data.js';
5+
import { FavoritesMemo } from './Favorites.js';
6+
7+
export const favoritesExamples = {
8+
'favorites.no-dnd': {
9+
factory: () => (
10+
<FavoritesMemo
11+
favorites={favorites.many.favorites}
12+
expansion={'expanded'}
13+
toggle={noop('toggle')}
14+
add={noop('add')}
15+
openFavorite={noop('openFavorite')}
16+
openContextMenu={noop('openContextMenu')}
17+
/>
18+
),
19+
},
20+
};

0 commit comments

Comments
 (0)