mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-18 00:57:51 +02:00
refactor(website): refactor showcase components (#10023)
This commit is contained in:
parent
73016d4936
commit
964a4e458e
28 changed files with 727 additions and 826 deletions
|
@ -98,6 +98,13 @@ export {useDocsPreferredVersion} from './contexts/docsPreferredVersion';
|
|||
|
||||
export {processAdmonitionProps} from './utils/admonitionUtils';
|
||||
|
||||
export {
|
||||
useHistorySelector,
|
||||
useQueryString,
|
||||
useQueryStringList,
|
||||
useClearQueryString,
|
||||
} from './utils/historyUtils';
|
||||
|
||||
export {
|
||||
SkipToContentFallbackId,
|
||||
SkipToContentLink,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {useCallback, useEffect, useSyncExternalStore} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useSyncExternalStore} from 'react';
|
||||
import {useHistory} from '@docusaurus/router';
|
||||
import {useEvent} from './reactUtils';
|
||||
|
||||
|
@ -74,41 +74,86 @@ export function useQueryStringValue(key: string | null): string | null {
|
|||
});
|
||||
}
|
||||
|
||||
export function useQueryStringKeySetter(): (
|
||||
function useQueryStringUpdater(
|
||||
key: string,
|
||||
newValue: string | null,
|
||||
options?: {push: boolean},
|
||||
) => void {
|
||||
): (newValue: string | null, options?: {push: boolean}) => void {
|
||||
const history = useHistory();
|
||||
return useCallback(
|
||||
(key, newValue, options) => {
|
||||
(newValue, options) => {
|
||||
const searchParams = new URLSearchParams(history.location.search);
|
||||
if (newValue) {
|
||||
searchParams.set(key, newValue);
|
||||
} else {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
const updaterFn = options?.push ? history.push : history.replace;
|
||||
updaterFn({
|
||||
const updateHistory = options?.push ? history.push : history.replace;
|
||||
updateHistory({
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
},
|
||||
[history],
|
||||
[key, history],
|
||||
);
|
||||
}
|
||||
|
||||
export function useQueryString(
|
||||
key: string,
|
||||
): [string, (newValue: string, options?: {push: boolean}) => void] {
|
||||
): [string, (newValue: string | null, options?: {push: boolean}) => void] {
|
||||
const value = useQueryStringValue(key) ?? '';
|
||||
const setQueryString = useQueryStringKeySetter();
|
||||
return [
|
||||
value,
|
||||
useCallback(
|
||||
(newValue: string, options) => {
|
||||
setQueryString(key, newValue, options);
|
||||
},
|
||||
[setQueryString, key],
|
||||
),
|
||||
];
|
||||
const update = useQueryStringUpdater(key);
|
||||
return [value, update];
|
||||
}
|
||||
|
||||
function useQueryStringListValues(key: string): string[] {
|
||||
// Unfortunately we can't just use searchParams.getAll(key) in the selector
|
||||
// It would create a new array every time and lead to an infinite loop...
|
||||
// The selector has to return a primitive/string value to avoid that...
|
||||
const arrayJsonString = useHistorySelector((history) => {
|
||||
const values = new URLSearchParams(history.location.search).getAll(key);
|
||||
return JSON.stringify(values);
|
||||
});
|
||||
return useMemo(() => JSON.parse(arrayJsonString), [arrayJsonString]);
|
||||
}
|
||||
|
||||
type ListUpdate = string[] | ((oldValues: string[]) => string[]);
|
||||
type ListUpdateFunction = (
|
||||
update: ListUpdate,
|
||||
options?: {push: boolean},
|
||||
) => void;
|
||||
|
||||
function useQueryStringListUpdater(key: string): ListUpdateFunction {
|
||||
const history = useHistory();
|
||||
const setValues: ListUpdateFunction = useCallback(
|
||||
(update, options) => {
|
||||
const searchParams = new URLSearchParams(history.location.search);
|
||||
const newValues = Array.isArray(update)
|
||||
? update
|
||||
: update(searchParams.getAll(key));
|
||||
searchParams.delete(key);
|
||||
newValues.forEach((v) => searchParams.append(key, v));
|
||||
|
||||
const updateHistory = options?.push ? history.push : history.replace;
|
||||
updateHistory({
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
},
|
||||
[history, key],
|
||||
);
|
||||
return setValues;
|
||||
}
|
||||
|
||||
export function useQueryStringList(
|
||||
key: string,
|
||||
): [string[], ListUpdateFunction] {
|
||||
const values = useQueryStringListValues(key);
|
||||
const setValues = useQueryStringListUpdater(key);
|
||||
return [values, setValues];
|
||||
}
|
||||
|
||||
export function useClearQueryString(): () => void {
|
||||
const history = useHistory();
|
||||
return useCallback(() => {
|
||||
history.replace({
|
||||
search: undefined,
|
||||
});
|
||||
}, [history]);
|
||||
}
|
||||
|
|
|
@ -301,7 +301,6 @@ rtcts
|
|||
rtlcss
|
||||
saurus
|
||||
Scaleway
|
||||
searchbar
|
||||
Sebastien
|
||||
sebastien
|
||||
sebastienlorber
|
||||
|
|
|
@ -49,7 +49,6 @@
|
|||
"@docusaurus/theme-mermaid": "3.2.1",
|
||||
"@docusaurus/utils": "3.2.1",
|
||||
"@docusaurus/utils-common": "3.2.1",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@swc/core": "1.2.197",
|
||||
"clsx": "^2.0.0",
|
||||
"color": "^4.2.3",
|
||||
|
@ -61,7 +60,6 @@
|
|||
"react-dom": "^18.0.0",
|
||||
"react-lite-youtube-embed": "^2.3.52",
|
||||
"react-medium-image-zoom": "^5.1.6",
|
||||
"react-popper": "^2.3.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"swc-loader": "^0.2.3",
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Svg, {type SvgIconProps} from '@site/src/components/Svg';
|
||||
|
||||
export default function FavoriteIcon(
|
||||
props: Omit<SvgIconProps, 'children'>,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
|
@ -14,11 +14,6 @@
|
|||
*/
|
||||
--site-primary-hue-saturation: 167 68%;
|
||||
--site-primary-hue-saturation-light: 167 56%; /* do we really need this extra one? */
|
||||
--site-color-favorite-background: #f6fdfd;
|
||||
--site-color-tooltip: #fff;
|
||||
--site-color-tooltip-background: #353738;
|
||||
--site-color-svg-icon-favorite: #e9669e;
|
||||
--site-color-checkbox-checked-bg: hsl(167deg 56% 73% / 25%);
|
||||
--site-color-feedback-background: #f0f8ff;
|
||||
--docusaurus-highlighted-code-line-bg: rgb(0 0 0 / 10%);
|
||||
/* Use a darker color to ensure contrast, ideally we don't need important */
|
||||
|
@ -28,8 +23,6 @@
|
|||
|
||||
html[data-theme='dark'] {
|
||||
--site-color-feedback-background: #2a2929;
|
||||
--site-color-favorite-background: #1d1e1e;
|
||||
--site-color-checkbox-checked-bg: hsl(167deg 56% 73% / 10%);
|
||||
--docusaurus-highlighted-code-line-bg: rgb(66 66 66 / 35%);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {type ReactNode} from 'react';
|
||||
import {useClearQueryString} from '@docusaurus/theme-common';
|
||||
|
||||
export default function ClearAllButton(): ReactNode {
|
||||
const clearQueryString = useClearQueryString();
|
||||
// TODO translate
|
||||
return (
|
||||
<button
|
||||
className="button button--outline button--primary"
|
||||
type="button"
|
||||
onClick={() => clearQueryString()}>
|
||||
Clear All
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {type ComponentProps} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: ComponentProps<'svg'>['style'];
|
||||
size: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export default function FavoriteIcon({
|
||||
size,
|
||||
className,
|
||||
style,
|
||||
}: Props): React.ReactNode {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={clsx(styles.svg, styles[size], className)}
|
||||
style={style}>
|
||||
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.svg {
|
||||
user-select: none;
|
||||
color: #e9669e;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.medium {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: 1.8rem;
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useId} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useOperator} from '../../_utils';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function OperatorButton() {
|
||||
const id = useId();
|
||||
const [operator, toggleOperator] = useOperator();
|
||||
// TODO add translations
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="screen-reader-only"
|
||||
aria-label="Toggle between or and and for the tags you selected"
|
||||
checked={operator === 'AND'}
|
||||
onChange={toggleOperator}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
toggleOperator();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={id} className={clsx(styles.checkboxLabel, 'shadow--md')}>
|
||||
{/* eslint-disable @docusaurus/no-untranslated-text */}
|
||||
<span className={styles.checkboxLabelOr}>OR</span>
|
||||
<span className={styles.checkboxLabelAnd}>AND</span>
|
||||
{/* eslint-enable @docusaurus/no-untranslated-text */}
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -10,27 +10,28 @@ import clsx from 'clsx';
|
|||
import Link from '@docusaurus/Link';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Image from '@theme/IdealImage';
|
||||
import FavoriteIcon from '@site/src/components/svgIcons/FavoriteIcon';
|
||||
import {
|
||||
Tags,
|
||||
TagList,
|
||||
type TagType,
|
||||
type User,
|
||||
type Tag,
|
||||
} from '@site/src/data/users';
|
||||
import {Tags, TagList, type TagType, type User} from '@site/src/data/users';
|
||||
import {sortBy} from '@site/src/utils/jsUtils';
|
||||
import Heading from '@theme/Heading';
|
||||
import Tooltip from '../ShowcaseTooltip';
|
||||
import FavoriteIcon from '../FavoriteIcon';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const TagComp = React.forwardRef<HTMLLIElement, Tag>(
|
||||
({label, color, description}, ref) => (
|
||||
<li ref={ref} className={styles.tag} title={description}>
|
||||
function TagItem({
|
||||
label,
|
||||
description,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<li className={styles.tag} title={description}>
|
||||
<span className={styles.textLabel}>{label.toLowerCase()}</span>
|
||||
<span className={styles.colorLabel} style={{backgroundColor: color}} />
|
||||
</li>
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseCardTag({tags}: {tags: TagType[]}) {
|
||||
const tagObjects = tags.map((tag) => ({tag, ...Tags[tag]}));
|
||||
|
@ -43,17 +44,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
|
|||
return (
|
||||
<>
|
||||
{tagObjectsSorted.map((tagObject, index) => {
|
||||
const id = `showcase_card_tag_${tagObject.tag}`;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
text={tagObject.description}
|
||||
anchorEl="#__docusaurus"
|
||||
id={id}>
|
||||
<TagComp key={index} {...tagObject} />
|
||||
</Tooltip>
|
||||
);
|
||||
return <TagItem key={index} {...tagObject} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
@ -62,6 +53,7 @@ function ShowcaseCardTag({tags}: {tags: TagType[]}) {
|
|||
function getCardImage(user: User): string {
|
||||
return (
|
||||
user.preview ??
|
||||
// TODO make it configurable
|
||||
`https://slorber-api-screenshot.netlify.app/${encodeURIComponent(
|
||||
user.website,
|
||||
)}/showcase`
|
||||
|
@ -83,7 +75,7 @@ function ShowcaseCard({user}: {user: User}) {
|
|||
</Link>
|
||||
</Heading>
|
||||
{user.tags.includes('favorite') && (
|
||||
<FavoriteIcon svgClass={styles.svgIconFavorite} size="small" />
|
||||
<FavoriteIcon size="medium" style={{marginRight: '0.25rem'}} />
|
||||
)}
|
||||
{user.source && (
|
||||
<Link
|
||||
|
|
|
@ -37,14 +37,10 @@
|
|||
}
|
||||
|
||||
.showcaseCardTitle,
|
||||
.showcaseCardHeader .svgIconFavorite {
|
||||
.showcaseCardHeader {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.showcaseCardHeader .svgIconFavorite {
|
||||
color: var(--site-color-svg-icon-favorite);
|
||||
}
|
||||
|
||||
.showcaseCardSrcBtn {
|
||||
margin-left: 6px;
|
||||
padding-left: 12px;
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import {sortedUsers, type User} from '@site/src/data/users';
|
||||
import Heading from '@theme/Heading';
|
||||
import FavoriteIcon from '../FavoriteIcon';
|
||||
import ShowcaseCard from '../ShowcaseCard';
|
||||
import {useFilteredUsers} from '../../_utils';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const favoriteUsers = sortedUsers.filter((user) =>
|
||||
user.tags.includes('favorite'),
|
||||
);
|
||||
|
||||
const otherUsers = sortedUsers.filter(
|
||||
(user) => !user.tags.includes('favorite'),
|
||||
);
|
||||
|
||||
function HeadingNoResult() {
|
||||
return (
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.usersList.noResult">No result</Translate>
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingFavorites() {
|
||||
return (
|
||||
<Heading as="h2" className={styles.headingFavorites}>
|
||||
<Translate id="showcase.favoritesList.title">Our favorites</Translate>
|
||||
<FavoriteIcon size="large" style={{marginLeft: '1rem'}} />
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingAllSites() {
|
||||
return (
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.usersList.allUsers">All sites</Translate>
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
function CardList({heading, items}: {heading?: ReactNode; items: User[]}) {
|
||||
return (
|
||||
<div className="container">
|
||||
{heading}
|
||||
<ul className={clsx('clean-list', styles.cardList)}>
|
||||
{items.map((item) => (
|
||||
<ShowcaseCard key={item.title} user={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoResultSection() {
|
||||
return (
|
||||
<section className="margin-top--lg margin-bottom--xl">
|
||||
<div className="container padding-vert--md text--center">
|
||||
<HeadingNoResult />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShowcaseCards() {
|
||||
const filteredUsers = useFilteredUsers();
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
return <NoResultSection />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="margin-top--lg margin-bottom--xl">
|
||||
{filteredUsers.length === sortedUsers.length ? (
|
||||
<>
|
||||
<div className={styles.showcaseFavorite}>
|
||||
<CardList heading={<HeadingFavorites />} items={favoriteUsers} />
|
||||
</div>
|
||||
<div className="margin-top--lg">
|
||||
<CardList heading={<HeadingAllSites />} items={otherUsers} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CardList items={filteredUsers} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.cardList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.showcaseFavorite {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
background-color: #f6fdfd;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .showcaseFavorite {
|
||||
background-color: #232525;
|
||||
}
|
||||
|
||||
.headingFavorites {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useState, useEffect, useCallback} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useHistory, useLocation} from '@docusaurus/router';
|
||||
|
||||
import {prepareUserState} from '../../index';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export type Operator = 'OR' | 'AND';
|
||||
|
||||
export const OperatorQueryKey = 'operator';
|
||||
|
||||
export function readOperator(search: string): Operator {
|
||||
return (new URLSearchParams(search).get(OperatorQueryKey) ??
|
||||
'OR') as Operator;
|
||||
}
|
||||
|
||||
export default function ShowcaseFilterToggle(): JSX.Element {
|
||||
const id = 'showcase_filter_toggle';
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [operator, setOperator] = useState(false);
|
||||
useEffect(() => {
|
||||
setOperator(readOperator(location.search) === 'AND');
|
||||
}, [location]);
|
||||
const toggleOperator = useCallback(() => {
|
||||
setOperator((o) => !o);
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.delete(OperatorQueryKey);
|
||||
if (!operator) {
|
||||
searchParams.append(OperatorQueryKey, 'AND');
|
||||
}
|
||||
history.push({
|
||||
...location,
|
||||
search: searchParams.toString(),
|
||||
state: prepareUserState(),
|
||||
});
|
||||
}, [operator, location, history]);
|
||||
|
||||
const ClearTag = () => {
|
||||
history.push({
|
||||
...location,
|
||||
search: '',
|
||||
state: prepareUserState(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row" style={{alignItems: 'center'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
className="screen-reader-only"
|
||||
aria-label="Toggle between or and and for the tags you selected"
|
||||
onChange={toggleOperator}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
toggleOperator();
|
||||
}
|
||||
}}
|
||||
checked={operator}
|
||||
/>
|
||||
<label htmlFor={id} className={clsx(styles.checkboxLabel, 'shadow--md')}>
|
||||
{/* eslint-disable @docusaurus/no-untranslated-text */}
|
||||
<span className={styles.checkboxLabelOr}>OR</span>
|
||||
<span className={styles.checkboxLabelAnd}>AND</span>
|
||||
{/* eslint-enable @docusaurus/no-untranslated-text */}
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="button button--outline button--primary"
|
||||
type="button"
|
||||
onClick={() => ClearTag()}>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
109
website/src/pages/showcase/_components/ShowcaseFilters/index.tsx
Normal file
109
website/src/pages/showcase/_components/ShowcaseFilters/index.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import type {ReactNode, CSSProperties} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import FavoriteIcon from '@site/src/pages/showcase/_components/FavoriteIcon';
|
||||
import {Tags, TagList, type TagType} from '@site/src/data/users';
|
||||
import Heading from '@theme/Heading';
|
||||
import ShowcaseTagSelect from '../ShowcaseTagSelect';
|
||||
import OperatorButton from '../OperatorButton';
|
||||
import ClearAllButton from '../ClearAllButton';
|
||||
import {useFilteredUsers, useSiteCountPlural} from '../../_utils';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function TagCircleIcon({color, style}: {color: string; style?: CSSProperties}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseTagListItem({tag}: {tag: TagType}) {
|
||||
const {label, description, color} = Tags[tag];
|
||||
return (
|
||||
<li className={styles.tagListItem}>
|
||||
<ShowcaseTagSelect
|
||||
tag={tag}
|
||||
label={label}
|
||||
description={description}
|
||||
icon={
|
||||
tag === 'favorite' ? (
|
||||
<FavoriteIcon size="small" style={{marginLeft: 8}} />
|
||||
) : (
|
||||
<TagCircleIcon
|
||||
color={color}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseTagList() {
|
||||
return (
|
||||
<ul className={clsx('clean-list', styles.tagList)}>
|
||||
{TagList.map((tag) => {
|
||||
return <ShowcaseTagListItem key={tag} tag={tag} />;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingText() {
|
||||
const filteredUsers = useFilteredUsers();
|
||||
const siteCountPlural = useSiteCountPlural();
|
||||
return (
|
||||
<div className={styles.headingText}>
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.filters.title">Filters</Translate>
|
||||
</Heading>
|
||||
<span>{siteCountPlural(filteredUsers.length)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingButtons() {
|
||||
return (
|
||||
<div className={styles.headingButtons} style={{alignItems: 'center'}}>
|
||||
<OperatorButton />
|
||||
<ClearAllButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadingRow() {
|
||||
return (
|
||||
<div className={clsx('margin-bottom--sm', styles.headingRow)}>
|
||||
<HeadingText />
|
||||
<HeadingButtons />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShowcaseFilters(): ReactNode {
|
||||
return (
|
||||
<section className="container margin-top--l margin-bottom--lg">
|
||||
<HeadingRow />
|
||||
<ShowcaseTagList />
|
||||
</section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.headingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.headingText {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.headingText > h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.headingText > span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.headingButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headingButtons > * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tagListItem {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
height: 32px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.tagListItem:last-child {
|
||||
margin-right: 0;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {type ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import {useSearchName} from '@site/src/pages/showcase/_utils';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function ShowcaseSearchBar(): ReactNode {
|
||||
const [searchName, setSearchName] = useSearchName();
|
||||
return (
|
||||
<div className={styles.searchBar}>
|
||||
<input
|
||||
placeholder={translate({
|
||||
message: 'Search for site name...',
|
||||
id: 'showcase.searchBar.placeholder',
|
||||
})}
|
||||
value={searchName}
|
||||
onInput={(e) => {
|
||||
setSearchName(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.searchBar {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.searchBar input {
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid gray;
|
||||
}
|
|
@ -7,90 +7,65 @@
|
|||
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type ReactElement,
|
||||
useId,
|
||||
} from 'react';
|
||||
import {useHistory, useLocation} from '@docusaurus/router';
|
||||
import {toggleListItem} from '@site/src/utils/jsUtils';
|
||||
import type {TagType} from '@site/src/data/users';
|
||||
import {useTags} from '../../_utils';
|
||||
|
||||
import {prepareUserState} from '../../index';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface Props extends ComponentProps<'input'> {
|
||||
icon: ReactElement<ComponentProps<'svg'>>;
|
||||
label: ReactNode;
|
||||
tag: TagType;
|
||||
}
|
||||
|
||||
const TagQueryStringKey = 'tags';
|
||||
|
||||
export function readSearchTags(search: string): TagType[] {
|
||||
return new URLSearchParams(search).getAll(TagQueryStringKey) as TagType[];
|
||||
}
|
||||
|
||||
function replaceSearchTags(search: string, newTags: TagType[]) {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
searchParams.delete(TagQueryStringKey);
|
||||
newTags.forEach((tag) => searchParams.append(TagQueryStringKey, tag));
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
function ShowcaseTagSelect(
|
||||
{id, icon, label, tag, ...rest}: Props,
|
||||
ref: React.ForwardedRef<HTMLLabelElement>,
|
||||
) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [selected, setSelected] = useState(false);
|
||||
useEffect(() => {
|
||||
const tags = readSearchTags(location.search);
|
||||
setSelected(tags.includes(tag));
|
||||
}, [tag, location]);
|
||||
const toggleTag = useCallback(() => {
|
||||
const tags = readSearchTags(location.search);
|
||||
const newTags = toggleListItem(tags, tag);
|
||||
const newSearch = replaceSearchTags(location.search, newTags);
|
||||
history.push({
|
||||
...location,
|
||||
search: newSearch,
|
||||
state: prepareUserState(),
|
||||
function useTagState(tag: string) {
|
||||
const [tags, setTags] = useTags();
|
||||
const isSelected = tags.includes(tag);
|
||||
const toggle = useCallback(() => {
|
||||
setTags((list) => {
|
||||
return list.includes(tag)
|
||||
? list.filter((t) => t !== tag)
|
||||
: [...list, tag];
|
||||
});
|
||||
}, [tag, location, history]);
|
||||
}, [tag, setTags]);
|
||||
|
||||
return [isSelected, toggle] as const;
|
||||
}
|
||||
|
||||
interface Props extends ComponentProps<'input'> {
|
||||
tag: TagType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactElement<ComponentProps<'svg'>>;
|
||||
}
|
||||
|
||||
export default function ShowcaseTagSelect({
|
||||
icon,
|
||||
label,
|
||||
description,
|
||||
tag,
|
||||
...rest
|
||||
}: Props): ReactNode {
|
||||
const id = useId();
|
||||
const [isSelected, toggle] = useTagState(tag);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={isSelected}
|
||||
onChange={toggle}
|
||||
className="screen-reader-only"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
toggleTag();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (e.relatedTarget) {
|
||||
e.target.nextElementSibling?.dispatchEvent(
|
||||
new KeyboardEvent('focus'),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.nextElementSibling?.dispatchEvent(new KeyboardEvent('blur'));
|
||||
}}
|
||||
onChange={toggleTag}
|
||||
checked={selected}
|
||||
{...rest}
|
||||
/>
|
||||
<label ref={ref} htmlFor={id} className={styles.checkboxLabel}>
|
||||
<label htmlFor={id} className={styles.checkboxLabel} title={description}>
|
||||
{label}
|
||||
{icon}
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(ShowcaseTagSelect);
|
||||
|
|
|
@ -28,7 +28,7 @@ input:focus-visible + .checkboxLabel {
|
|||
|
||||
input:checked + .checkboxLabel {
|
||||
opacity: 0.9;
|
||||
background-color: var(--site-color-checkbox-checked-bg);
|
||||
background-color: hsl(167deg 56% 73% / 25%);
|
||||
border: 2px solid var(--ifm-color-primary-darkest);
|
||||
}
|
||||
|
||||
|
@ -36,3 +36,7 @@ input:checked + .checkboxLabel:hover {
|
|||
opacity: 0.75;
|
||||
box-shadow: 0 0 2px 1px var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] input:checked + .checkboxLabel {
|
||||
background-color: hsl(167deg 56% 73% / 10%);
|
||||
}
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {useEffect, useState, useRef} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {usePopper} from 'react-popper';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface Props {
|
||||
anchorEl?: HTMLElement | string;
|
||||
id: string;
|
||||
text: string;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
id,
|
||||
anchorEl,
|
||||
text,
|
||||
}: Props): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
const [arrowElement, setArrowElement] = useState<HTMLElement | null>(null);
|
||||
const [container, setContainer] = useState<Element | null>(null);
|
||||
const {styles: popperStyles, attributes} = usePopper(
|
||||
referenceElement,
|
||||
popperElement,
|
||||
{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'arrow',
|
||||
options: {
|
||||
element: arrowElement,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const timeout = useRef<number | null>(null);
|
||||
const tooltipId = `${id}_tooltip`;
|
||||
|
||||
useEffect(() => {
|
||||
if (anchorEl) {
|
||||
if (typeof anchorEl === 'string') {
|
||||
setContainer(document.querySelector(anchorEl));
|
||||
} else {
|
||||
setContainer(anchorEl);
|
||||
}
|
||||
} else {
|
||||
setContainer(document.body);
|
||||
}
|
||||
}, [container, anchorEl]);
|
||||
|
||||
useEffect(() => {
|
||||
const showEvents = ['mouseenter', 'focus'];
|
||||
const hideEvents = ['mouseleave', 'blur'];
|
||||
|
||||
const handleOpen = () => {
|
||||
// There is no point in displaying an empty tooltip.
|
||||
if (text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the title ahead of time to avoid displaying
|
||||
// two tooltips at the same time (native + this one).
|
||||
referenceElement?.removeAttribute('title');
|
||||
|
||||
timeout.current = window.setTimeout(() => {
|
||||
setOpen(true);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearInterval(timeout.current!);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (referenceElement) {
|
||||
showEvents.forEach((event) => {
|
||||
referenceElement.addEventListener(event, handleOpen);
|
||||
});
|
||||
|
||||
hideEvents.forEach((event) => {
|
||||
referenceElement.addEventListener(event, handleClose);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (referenceElement) {
|
||||
showEvents.forEach((event) => {
|
||||
referenceElement.removeEventListener(event, handleOpen);
|
||||
});
|
||||
|
||||
hideEvents.forEach((event) => {
|
||||
referenceElement.removeEventListener(event, handleClose);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [referenceElement, text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: setReferenceElement,
|
||||
'aria-describedby': open ? tooltipId : undefined,
|
||||
})}
|
||||
{container
|
||||
? ReactDOM.createPortal(
|
||||
open && (
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
ref={setPopperElement}
|
||||
className={styles.tooltip}
|
||||
style={popperStyles.popper}
|
||||
{...attributes.popper}>
|
||||
{text}
|
||||
<span
|
||||
ref={setArrowElement}
|
||||
className={styles.tooltipArrow}
|
||||
style={popperStyles.arrow}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
container,
|
||||
)
|
||||
: container}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.tooltip {
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: var(--site-color-tooltip);
|
||||
background: var(--site-color-tooltip-background);
|
||||
font-size: 0.8rem;
|
||||
z-index: 500;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.tooltipArrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tooltipArrow,
|
||||
.tooltipArrow::before {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.tooltipArrow::before {
|
||||
visibility: visible;
|
||||
content: '';
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip[data-popper-placement^='top'] > .tooltipArrow {
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.tooltip[data-popper-placement^='bottom'] > .tooltipArrow {
|
||||
top: -4px;
|
||||
}
|
99
website/src/pages/showcase/_utils.tsx
Normal file
99
website/src/pages/showcase/_utils.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import {
|
||||
usePluralForm,
|
||||
useQueryString,
|
||||
useQueryStringList,
|
||||
} from '@docusaurus/theme-common';
|
||||
import type {TagType, User} from '@site/src/data/users';
|
||||
import {sortedUsers} from '@site/src/data/users';
|
||||
|
||||
export function useSearchName() {
|
||||
return useQueryString('name');
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQueryStringList('tags');
|
||||
}
|
||||
|
||||
type Operator = 'OR' | 'AND';
|
||||
|
||||
export function useOperator() {
|
||||
const [searchOperator, setSearchOperator] = useQueryString('operator');
|
||||
const operator: Operator = searchOperator === 'AND' ? 'AND' : 'OR';
|
||||
const toggleOperator = useCallback(() => {
|
||||
const newOperator = operator === 'OR' ? 'AND' : null;
|
||||
setSearchOperator(newOperator);
|
||||
}, [operator, setSearchOperator]);
|
||||
return [operator, toggleOperator] as const;
|
||||
}
|
||||
|
||||
function filterUsers({
|
||||
users,
|
||||
tags,
|
||||
operator,
|
||||
searchName,
|
||||
}: {
|
||||
users: User[];
|
||||
tags: TagType[];
|
||||
operator: Operator;
|
||||
searchName: string | null;
|
||||
}) {
|
||||
if (searchName) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
users = users.filter((user) =>
|
||||
user.title.toLowerCase().includes(searchName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
if (tags.length === 0) {
|
||||
return users;
|
||||
}
|
||||
return users.filter((user) => {
|
||||
if (user.tags.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (operator === 'AND') {
|
||||
return tags.every((tag) => user.tags.includes(tag));
|
||||
}
|
||||
return tags.some((tag) => user.tags.includes(tag));
|
||||
});
|
||||
}
|
||||
|
||||
export function useFilteredUsers() {
|
||||
const [tags] = useTags();
|
||||
const [searchName] = useSearchName();
|
||||
const [operator] = useOperator();
|
||||
return useMemo(
|
||||
() =>
|
||||
filterUsers({
|
||||
users: sortedUsers,
|
||||
tags: tags as TagType[],
|
||||
operator,
|
||||
searchName,
|
||||
}),
|
||||
[tags, operator, searchName],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSiteCountPlural() {
|
||||
const {selectMessage} = usePluralForm();
|
||||
return (sitesCount: number) =>
|
||||
selectMessage(
|
||||
sitesCount,
|
||||
translate(
|
||||
{
|
||||
id: 'showcase.filters.resultCount',
|
||||
description:
|
||||
'Pluralized label for the number of sites found on the showcase. Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
|
||||
message: '1 site|{sitesCount} sites',
|
||||
},
|
||||
{sitesCount},
|
||||
),
|
||||
);
|
||||
}
|
|
@ -5,35 +5,15 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {useState, useMemo, useEffect} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import {useHistory, useLocation} from '@docusaurus/router';
|
||||
import {usePluralForm} from '@docusaurus/theme-common';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import Layout from '@theme/Layout';
|
||||
import FavoriteIcon from '@site/src/components/svgIcons/FavoriteIcon';
|
||||
import {
|
||||
sortedUsers,
|
||||
Tags,
|
||||
TagList,
|
||||
type User,
|
||||
type TagType,
|
||||
} from '@site/src/data/users';
|
||||
import Heading from '@theme/Heading';
|
||||
import ShowcaseTagSelect, {
|
||||
readSearchTags,
|
||||
} from './_components/ShowcaseTagSelect';
|
||||
import ShowcaseFilterToggle, {
|
||||
type Operator,
|
||||
readOperator,
|
||||
} from './_components/ShowcaseFilterToggle';
|
||||
import ShowcaseCard from './_components/ShowcaseCard';
|
||||
import ShowcaseTooltip from './_components/ShowcaseTooltip';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
import ShowcaseSearchBar from '@site/src/pages/showcase/_components/ShowcaseSearchBar';
|
||||
import ShowcaseCards from './_components/ShowcaseCards';
|
||||
import ShowcaseFilters from './_components/ShowcaseFilters';
|
||||
|
||||
const TITLE = translate({message: 'Docusaurus Site Showcase'});
|
||||
const DESCRIPTION = translate({
|
||||
|
@ -41,85 +21,6 @@ const DESCRIPTION = translate({
|
|||
});
|
||||
const SUBMIT_URL = 'https://github.com/facebook/docusaurus/discussions/7826';
|
||||
|
||||
type UserState = {
|
||||
scrollTopPosition: number;
|
||||
focusedElementId: string | undefined;
|
||||
};
|
||||
|
||||
function restoreUserState(userState: UserState | null) {
|
||||
const {scrollTopPosition, focusedElementId} = userState ?? {
|
||||
scrollTopPosition: 0,
|
||||
focusedElementId: undefined,
|
||||
};
|
||||
// @ts-expect-error: if focusedElementId is undefined it returns null
|
||||
document.getElementById(focusedElementId)?.focus();
|
||||
window.scrollTo({top: scrollTopPosition});
|
||||
}
|
||||
|
||||
export function prepareUserState(): UserState | undefined {
|
||||
if (ExecutionEnvironment.canUseDOM) {
|
||||
return {
|
||||
scrollTopPosition: window.scrollY,
|
||||
focusedElementId: document.activeElement?.id,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const SearchNameQueryKey = 'name';
|
||||
|
||||
function readSearchName(search: string) {
|
||||
return new URLSearchParams(search).get(SearchNameQueryKey);
|
||||
}
|
||||
|
||||
function filterUsers(
|
||||
users: User[],
|
||||
selectedTags: TagType[],
|
||||
operator: Operator,
|
||||
searchName: string | null,
|
||||
) {
|
||||
if (searchName) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
users = users.filter((user) =>
|
||||
user.title.toLowerCase().includes(searchName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
if (selectedTags.length === 0) {
|
||||
return users;
|
||||
}
|
||||
return users.filter((user) => {
|
||||
if (user.tags.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (operator === 'AND') {
|
||||
return selectedTags.every((tag) => user.tags.includes(tag));
|
||||
}
|
||||
return selectedTags.some((tag) => user.tags.includes(tag));
|
||||
});
|
||||
}
|
||||
|
||||
function useFilteredUsers() {
|
||||
const location = useLocation<UserState>();
|
||||
const [operator, setOperator] = useState<Operator>('OR');
|
||||
// On SSR / first mount (hydration) no tag is selected
|
||||
const [selectedTags, setSelectedTags] = useState<TagType[]>([]);
|
||||
const [searchName, setSearchName] = useState<string | null>(null);
|
||||
// Sync tags from QS to state (delayed on purpose to avoid SSR/Client
|
||||
// hydration mismatch)
|
||||
useEffect(() => {
|
||||
setSelectedTags(readSearchTags(location.search));
|
||||
setOperator(readOperator(location.search));
|
||||
setSearchName(readSearchName(location.search));
|
||||
restoreUserState(location.state);
|
||||
}, [location]);
|
||||
|
||||
return useMemo(
|
||||
() => filterUsers(sortedUsers, selectedTags, operator, searchName),
|
||||
[selectedTags, operator, searchName],
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseHeader() {
|
||||
return (
|
||||
<section className="margin-top--lg margin-bottom--lg text--center">
|
||||
|
@ -134,193 +35,6 @@ function ShowcaseHeader() {
|
|||
);
|
||||
}
|
||||
|
||||
function useSiteCountPlural() {
|
||||
const {selectMessage} = usePluralForm();
|
||||
return (sitesCount: number) =>
|
||||
selectMessage(
|
||||
sitesCount,
|
||||
translate(
|
||||
{
|
||||
id: 'showcase.filters.resultCount',
|
||||
description:
|
||||
'Pluralized label for the number of sites found on the showcase. Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
|
||||
message: '1 site|{sitesCount} sites',
|
||||
},
|
||||
{sitesCount},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseFilters() {
|
||||
const filteredUsers = useFilteredUsers();
|
||||
const siteCountPlural = useSiteCountPlural();
|
||||
return (
|
||||
<section className="container margin-top--l margin-bottom--lg">
|
||||
<div className={clsx('margin-bottom--sm', styles.filterCheckbox)}>
|
||||
<div>
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.filters.title">Filters</Translate>
|
||||
</Heading>
|
||||
<span>{siteCountPlural(filteredUsers.length)}</span>
|
||||
</div>
|
||||
<ShowcaseFilterToggle />
|
||||
</div>
|
||||
<ul className={clsx('clean-list', styles.checkboxList)}>
|
||||
{TagList.map((tag, i) => {
|
||||
const {label, description, color} = Tags[tag];
|
||||
const id = `showcase_checkbox_id_${tag}`;
|
||||
|
||||
return (
|
||||
<li key={i} className={styles.checkboxListItem}>
|
||||
<ShowcaseTooltip
|
||||
id={id}
|
||||
text={description}
|
||||
anchorEl="#__docusaurus">
|
||||
<ShowcaseTagSelect
|
||||
tag={tag}
|
||||
id={id}
|
||||
label={label}
|
||||
icon={
|
||||
tag === 'favorite' ? (
|
||||
<FavoriteIcon svgClass={styles.svgIconFavoriteXs} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ShowcaseTooltip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const favoriteUsers = sortedUsers.filter((user) =>
|
||||
user.tags.includes('favorite'),
|
||||
);
|
||||
const otherUsers = sortedUsers.filter(
|
||||
(user) => !user.tags.includes('favorite'),
|
||||
);
|
||||
|
||||
function SearchBar() {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setValue(readSearchName(location.search));
|
||||
}, [location]);
|
||||
return (
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
id="searchbar"
|
||||
placeholder={translate({
|
||||
message: 'Search for site name...',
|
||||
id: 'showcase.searchBar.placeholder',
|
||||
})}
|
||||
value={value ?? undefined}
|
||||
onInput={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
const newSearch = new URLSearchParams(location.search);
|
||||
newSearch.delete(SearchNameQueryKey);
|
||||
if (e.currentTarget.value) {
|
||||
newSearch.set(SearchNameQueryKey, e.currentTarget.value);
|
||||
}
|
||||
history.push({
|
||||
...location,
|
||||
search: newSearch.toString(),
|
||||
state: prepareUserState(),
|
||||
});
|
||||
setTimeout(() => {
|
||||
document.getElementById('searchbar')?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseCards() {
|
||||
const filteredUsers = useFilteredUsers();
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
return (
|
||||
<section className="margin-top--lg margin-bottom--xl">
|
||||
<div className="container padding-vert--md text--center">
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.usersList.noResult">No result</Translate>
|
||||
</Heading>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="margin-top--lg margin-bottom--xl">
|
||||
{filteredUsers.length === sortedUsers.length ? (
|
||||
<>
|
||||
<div className={styles.showcaseFavorite}>
|
||||
<div className="container">
|
||||
<div
|
||||
className={clsx(
|
||||
'margin-bottom--md',
|
||||
styles.showcaseFavoriteHeader,
|
||||
)}>
|
||||
<Heading as="h2">
|
||||
<Translate id="showcase.favoritesList.title">
|
||||
Our favorites
|
||||
</Translate>
|
||||
</Heading>
|
||||
<FavoriteIcon svgClass={styles.svgIconFavorite} />
|
||||
</div>
|
||||
<ul
|
||||
className={clsx(
|
||||
'container',
|
||||
'clean-list',
|
||||
styles.showcaseList,
|
||||
)}>
|
||||
{favoriteUsers.map((user) => (
|
||||
<ShowcaseCard key={user.title} user={user} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container margin-top--lg">
|
||||
<Heading as="h2" className={styles.showcaseHeader}>
|
||||
<Translate id="showcase.usersList.allUsers">All sites</Translate>
|
||||
</Heading>
|
||||
<ul className={clsx('clean-list', styles.showcaseList)}>
|
||||
{otherUsers.map((user) => (
|
||||
<ShowcaseCard key={user.title} user={user} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="container">
|
||||
<div
|
||||
className={clsx('margin-bottom--md', styles.showcaseFavoriteHeader)}
|
||||
/>
|
||||
<ul className={clsx('clean-list', styles.showcaseList)}>
|
||||
{filteredUsers.map((user) => (
|
||||
<ShowcaseCard key={user.title} user={user} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Showcase(): JSX.Element {
|
||||
return (
|
||||
<Layout title={TITLE} description={DESCRIPTION}>
|
||||
|
@ -330,7 +44,7 @@ export default function Showcase(): JSX.Element {
|
|||
<div
|
||||
style={{display: 'flex', marginLeft: 'auto'}}
|
||||
className="container">
|
||||
<SearchBar />
|
||||
<ShowcaseSearchBar />
|
||||
</div>
|
||||
<ShowcaseCards />
|
||||
</main>
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.filterCheckbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filterCheckbox,
|
||||
.checkboxList {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterCheckbox > div:first-child {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterCheckbox > div > * {
|
||||
margin-bottom: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.checkboxList {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkboxListItem {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
height: 32px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.checkboxListItem:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.searchContainer input {
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
|
||||
.showcaseList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.showcaseFavorite {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
background-color: var(--site-color-favorite-background);
|
||||
}
|
||||
|
||||
.showcaseFavoriteHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.showcaseFavoriteHeader > h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.showcaseFavoriteHeader > svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.svgIconFavoriteXs,
|
||||
.svgIconFavorite {
|
||||
color: var(--site-color-svg-icon-favorite);
|
||||
}
|
||||
|
||||
.svgIconFavoriteXs {
|
||||
margin-left: 0.625rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.svgIconFavorite {
|
||||
margin-left: 1rem;
|
||||
}
|
63
yarn.lock
63
yarn.lock
|
@ -1639,7 +1639,7 @@
|
|||
"@docsearch/css" "3.5.2"
|
||||
algoliasearch "^4.19.1"
|
||||
|
||||
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
"@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
|
@ -2629,11 +2629,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
|
||||
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
|
||||
|
||||
"@popperjs/core@^2.11.8":
|
||||
version "2.11.8"
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
|
||||
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
|
||||
|
||||
"@rollup/plugin-babel@^5.2.0":
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
|
||||
|
@ -13741,7 +13736,7 @@ react-error-overlay@^6.0.11:
|
|||
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
|
||||
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
|
||||
|
||||
react-fast-compare@^3.0.1, react-fast-compare@^3.2.0:
|
||||
react-fast-compare@^3.2.0:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
||||
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
|
||||
|
@ -13793,19 +13788,19 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
|||
dependencies:
|
||||
"@babel/runtime" "^7.10.3"
|
||||
|
||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-medium-image-zoom@^5.1.6:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-5.1.6.tgz#1ec9dabbc88da664f3aacc03a93cf79cb1b70a23"
|
||||
integrity sha512-0QolPce1vNJsF5HKrGkU1UT6kLNvY9EOnLBqz++LlVnBQduaHLkJlY73ayj3SxY09XWRrnxKDMTHPDkrQYdREw==
|
||||
|
||||
react-popper@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
|
||||
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
|
||||
dependencies:
|
||||
react-fast-compare "^3.0.1"
|
||||
warning "^4.0.2"
|
||||
|
||||
react-router-config@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||
|
@ -15173,7 +15168,16 @@ string-length@^4.0.1:
|
|||
char-regex "^1.0.2"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
@ -15272,7 +15276,14 @@ stringify-object@^3.3.0:
|
|||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
@ -16497,13 +16508,6 @@ walker@^1.0.8:
|
|||
dependencies:
|
||||
makeerror "1.0.12"
|
||||
|
||||
warning@^4.0.2:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
watchpack@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
|
||||
|
@ -16944,7 +16948,7 @@ workbox-window@7.0.0, workbox-window@^7.0.0:
|
|||
"@types/trusted-types" "^2.0.2"
|
||||
workbox-core "7.0.0"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
@ -16962,6 +16966,15 @@ wrap-ansi@^6.2.0:
|
|||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue