diff --git a/package.json b/package.json index 2e15fd6858..2c08839d6d 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "react-medium-image-zoom": "^5.4.1", "react-select": "^5.7.0", "remark-gfm": "^1.0.0", + "swr": "^2.4.0", "tailwind-merge": "^2.5.5", "typescript": "^4.6.3", "use-keyboard-shortcut": "^1.1.6", diff --git a/src/components/Examples/ExamplesFilter.tsx b/src/components/Examples/ExamplesFilter.tsx index a15d3bda3a..b2ac11a9a3 100644 --- a/src/components/Examples/ExamplesFilter.tsx +++ b/src/components/Examples/ExamplesFilter.tsx @@ -2,15 +2,15 @@ import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, u import ReactDOM from 'react-dom'; import { Input } from 'src/components/ui/Input'; import { products } from '../../data/examples'; -import Button from '@ably/ui/core/Button'; +import Button from 'src/components/ui/Button'; import cn from 'src/utilities/cn'; -import Badge from '@ably/ui/core/Badge'; +import Badge from 'src/components/ui/Badge'; import ExamplesCheckbox from './ExamplesCheckbox'; import { SelectedFilters } from './ExamplesContent'; import { useOnClickOutside } from 'src/hooks/use-on-click-outside'; import { navigate } from 'gatsby'; import { ProductName } from '@ably/ui/core/ProductTile/data'; -import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { AdjustmentsHorizontalIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; // Matches Tailwind's `sm` screen (768px), where the filter switches from the // mobile drawer (with an Apply button) to the inline desktop sidebar. Above @@ -135,7 +135,7 @@ const ExamplesFilter = ({ ; MockButton.displayName = 'MockButton'; return MockButton; diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index de7305490e..78abe89f2d 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -7,7 +7,7 @@ import { throttle } from 'es-toolkit/compat'; import cn from 'src/utilities/cn'; import Icon from 'src/components/Icon'; import TabMenu from '@ably/ui/core/TabMenu'; -import Logo from '@ably/ui/core/images/logo/ably-logo.svg'; +import Logo from 'src/images/ably-logo.svg'; import { track } from '@ably/ui/core/insights'; import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from 'src/utilities/heights'; import LeftSidebar from './LeftSidebar'; diff --git a/src/components/Layout/LanguageSelector.test.tsx b/src/components/Layout/LanguageSelector.test.tsx index b37a10ca4c..263c7fd3bb 100644 --- a/src/components/Layout/LanguageSelector.test.tsx +++ b/src/components/Layout/LanguageSelector.test.tsx @@ -14,7 +14,7 @@ jest.mock('src/components/Icon', () => ({ default: ({ name }: { name: string }) =>
{name}
, })); -jest.mock('@ably/ui/core/Badge', () => ({ +jest.mock('src/components/ui/Badge', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); diff --git a/src/components/Layout/LanguageSelector.tsx b/src/components/Layout/LanguageSelector.tsx index 36a4fe9291..02e37f22a9 100644 --- a/src/components/Layout/LanguageSelector.tsx +++ b/src/components/Layout/LanguageSelector.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useLocation } from '@reach/router'; -import Badge from '@ably/ui/core/Badge'; +import Badge from 'src/components/ui/Badge'; import Icon from 'src/components/Icon'; import { IconName } from 'src/components/Icon/types'; import cn from 'src/utilities/cn'; diff --git a/src/components/Layout/mdx/RequiredBadge.tsx b/src/components/Layout/mdx/RequiredBadge.tsx index 3770452d9f..7a28fab4db 100644 --- a/src/components/Layout/mdx/RequiredBadge.tsx +++ b/src/components/Layout/mdx/RequiredBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Badge from '@ably/ui/core/Badge'; +import Badge from 'src/components/ui/Badge'; // The wrapping div with data-toc-exclude prevents this badge's text from // appearing in the "On this page" sidebar when rendered inside a heading. diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000000..aa41383ef3 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,164 @@ +import React, { PropsWithChildren, ReactNode, useMemo } from 'react'; +import { IconSize } from 'src/components/Icon/types'; +import cn from 'src/utilities/cn'; +import { ColorClassColorGroups } from './colors'; +import IconSlot from './IconSlot'; + +/** + * Props for the Badge component. + */ +export interface BadgeProps { + /** + * The size of the badge. Can be one of "xs", "sm", "md", or "lg". + */ + size?: 'xs' | 'sm' | 'md' | 'lg'; + + /** + * The color of the badge. Can be a value from ColorClassColorGroups or "red". + */ + color?: ColorClassColorGroups | 'red'; + + /** + * An icon element to display before the children. Pass it unsized — the badge sizes it. + */ + iconBefore?: ReactNode; + + /** + * An icon element to display after the children. Pass it unsized — the badge sizes it. + */ + iconAfter?: ReactNode; + + /** + * Additional CSS class names to apply to the badge. + */ + className?: string; + + /** + * Whether the badge is disabled. Defaults to false. + */ + disabled?: boolean; + + /** + * Whether the badge is focusable. Defaults to false. + */ + focusable?: boolean; + + /** + * Whether the badge is hoverable. Defaults to false. + */ + hoverable?: boolean; + + /** + * The size of the icons in the badge. If not provided, it will be derived from the badge size. + */ + iconSize?: IconSize; + + /** + * Accessible label for the badge when interactive + */ + ariaLabel?: string; + + /** + * Additional CSS class names to apply to the children of the badge. + */ + childClassName?: string; +} + +const defaultIconSizeByBadgeSize: Record, IconSize> = { + lg: '16px', + md: '15px', + sm: '14px', + xs: '13px', +}; + +const Badge: React.FC> = ({ + size = 'md', + color = 'neutral', + iconBefore, + iconAfter, + className, + childClassName, + children, + disabled = false, + focusable = false, + hoverable = false, + iconSize, + ariaLabel, +}) => { + const sizeClass = useMemo(() => { + switch (size) { + case 'xs': + return 'px-2 py-0 text-[10px] leading-tight'; + case 'sm': + return 'px-2 py-0.5 text-[10px] leading-tight'; + case 'md': + return 'px-2.5 py-0.5 text-[11px] leading-normal'; + case 'lg': + return 'px-3 py-[0.1875rem] text-[12px] leading-normal'; + } + }, [size]); + + const childClass = useMemo(() => { + switch (size) { + case 'xs': + case 'sm': + return 'leading-[18px]'; + case 'md': + case 'lg': + return 'leading-[20px]'; + } + }, [size]); + + const colorClass = useMemo(() => { + switch (color) { + case 'neutral': + return 'text-neutral-900 dark:text-neutral-400'; + case 'violet': + return 'text-violet-400'; + case 'orange': + return 'text-orange-600'; + case 'yellow': + return 'text-yellow-600'; + case 'green': + return 'text-green-600'; + case 'blue': + return 'text-blue-600'; + case 'pink': + return 'text-pink-600'; + case 'red': + return 'text-orange-700'; + } + }, [color]); + + const computedIconSize = iconSize ?? defaultIconSizeByBadgeSize[size]; + + return ( +
+ {iconBefore ? : null} + + {children} + + {iconAfter ? : null} +
+ ); +}; + +export default Badge; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000000..f2b3f6c8a4 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,125 @@ +import React, { PropsWithChildren, ReactNode } from 'react'; +import cn from 'src/utilities/cn'; +import { ColorClass, ColorThemeSet } from './colors'; +import IconSlot from './IconSlot'; + +export type ButtonType = 'priority' | 'primary' | 'secondary'; + +type ButtonSize = 'lg' | 'md' | 'sm' | 'xs'; + +export type ButtonPropsBase = { + /** + * The type of button: priority, primary, or secondary. + */ + variant?: ButtonType; + /** + * The button size: lg, sm, or xs. Leave empty for md. + */ + size?: ButtonSize; + /** + * An icon element to render on the left side of the button label. Pass a Heroicon + * (e.g. ``) or an Ably glyph (``) unsized — the + * button sizes it. + */ + leftIcon?: ReactNode; + /** + * An icon element to render on the right side of the button label. See `leftIcon`. + */ + rightIcon?: ReactNode; + /** + * Optional classes to add to the button element. + */ + className?: string; + /** + * Optional color to apply to the icon on either left and/or right side of the button. + */ + iconColor?: ColorClass | ColorThemeSet; +}; + +type ButtonProps = ButtonPropsBase & React.ButtonHTMLAttributes; + +// got to go the long way round because of ol' mate Taily Waily +const buttonClasses: Record> = { + priority: { + lg: 'ui-button-priority-lg', + md: 'ui-button-priority', + sm: 'ui-button-priority-sm', + xs: 'ui-button-priority-xs', + }, + primary: { + lg: 'ui-button-primary-lg', + md: 'ui-button-primary', + sm: 'ui-button-primary-sm', + xs: 'ui-button-primary-xs', + }, + secondary: { + lg: 'ui-button-secondary-lg', + md: 'ui-button-secondary', + sm: 'ui-button-secondary-sm', + xs: 'ui-button-secondary-xs', + }, +}; + +export const iconModifierClasses: Record = { + lg: { left: 'ui-button-lg-left-icon', right: 'ui-button-lg-right-icon' }, + md: { left: 'ui-button-left-icon', right: 'ui-button-right-icon' }, + sm: { left: 'ui-button-sm-left-icon', right: 'ui-button-sm-right-icon' }, + xs: { left: '', right: '' }, +}; + +export const commonButtonProps = (props: ButtonPropsBase) => { + const { variant = 'primary', size, leftIcon, rightIcon, className } = props; + + return { + className: cn( + buttonClasses[variant][size ?? 'md'], + { [iconModifierClasses[size ?? 'md'].left]: leftIcon }, + { [iconModifierClasses[size ?? 'md'].right]: rightIcon }, + className, + ), + }; +}; + +// Per-size icon dimensions, matching the `[&>svg]:!w-6/5/4` rules the button base classes +// applied to a direct child (buttons.css) — reapplied via IconSlot since the icon is +// now wrapped in a . +const iconSizeByButtonSize: Record = { + lg: '1.5rem', + md: '1.5rem', + sm: '1.25rem', + xs: '1rem', +}; + +export const commonButtonInterior = (props: PropsWithChildren) => { + const { leftIcon, rightIcon, iconColor, size = 'md', children } = props; + const iconSize = iconSizeByButtonSize[size]; + return ( + <> + {leftIcon ? : null} + {children} + {rightIcon ? : null} + + ); +}; + +const Button: React.FC> = ({ + variant = 'primary', + size, + leftIcon, + rightIcon, + children, + className, + iconColor, + ...rest +}) => { + return ( + + ); +}; + +export default Button; diff --git a/src/components/ui/FeaturedLink.tsx b/src/components/ui/FeaturedLink.tsx new file mode 100644 index 0000000000..e5ea3c93b8 --- /dev/null +++ b/src/components/ui/FeaturedLink.tsx @@ -0,0 +1,136 @@ +import React, { CSSProperties, ReactNode } from 'react'; + +import { ArrowLongRightIcon } from '@heroicons/react/24/outline'; +import { ColorClass, ColorThemeSet } from './colors'; +import cn from 'src/utilities/cn'; + +type FeaturedLinkProps = { + url: string; + children: ReactNode; + textSize?: string; + iconColor?: ColorClass | ColorThemeSet; + flush?: boolean; + reverse?: boolean; + additionalCSS?: string; + newWindow?: boolean; + onClick?: () => void; + disabled?: boolean; + /** + * Optional class name for the icon. + */ + iconClassName?: string; +}; + +type TargetProps = { target?: string; rel?: string }; + +// When generating links with target=_blank, we only add `noreferrer` to +// links that don't start with `/`, so we can continue tracking referrers +// across our own domains +const buildTargetAndRel = (url: string, newWindow: boolean) => { + const props: TargetProps = {}; + + if (newWindow) { + props.target = '_blank'; + + if (url.startsWith('/') && !url.startsWith('//')) { + props.rel = 'noopener'; + } else { + props.rel = 'noopener noreferrer'; + } + } + + return props; +}; + +const FeaturedLink = ({ + url, + textSize = 'text-p2', + iconColor, + flush = false, + reverse = false, + additionalCSS = '', + newWindow = false, + onClick = undefined, + children, + disabled = false, + iconClassName = '', +}: FeaturedLinkProps) => { + const targetAndRel = buildTargetAndRel(url, newWindow); + + return ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } + role={onClick && !url ? 'button' : undefined} + > + {reverse ? ( + <> + + {children} + + ) : ( + <> + {children} + + + )} + + ); +}; + +export default FeaturedLink; diff --git a/src/components/ui/IconSlot.tsx b/src/components/ui/IconSlot.tsx new file mode 100644 index 0000000000..841566aebb --- /dev/null +++ b/src/components/ui/IconSlot.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from 'react'; +import type { ClassValue } from 'clsx'; +import cn from 'src/utilities/cn'; + +// Sizes an icon element (a Heroicon or an Ably glyph) to `size` and tints it via +// `colorClass`. The `!` forces the dimensions onto a glyph , which otherwise sets its +// own inline width/height. The single home for the icon-sizing convention across the vendored +// components (Button/Badge/SegmentedControl/CodeSnippet). +const IconSlot = ({ icon, size, colorClass }: { icon: ReactNode; size: string; colorClass?: ClassValue }) => ( + svg]:!w-full [&>svg]:!h-full', colorClass)} + style={{ width: size, height: size }} + > + {icon} + +); + +export default IconSlot; diff --git a/src/components/ui/LinkButton.tsx b/src/components/ui/LinkButton.tsx new file mode 100644 index 0000000000..b6205d6099 --- /dev/null +++ b/src/components/ui/LinkButton.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { ButtonPropsBase, commonButtonInterior, commonButtonProps } from './Button'; +import cn from 'src/utilities/cn'; +import { ColorClass, ColorThemeSet } from './colors'; + +export type LinkButtonProps = ButtonPropsBase & { + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + iconColor?: ColorClass | ColorThemeSet; +} & React.AnchorHTMLAttributes; + +const LinkButton: React.FC = ({ + variant = 'primary', + size, + leftIcon, + rightIcon, + children, + className, + disabled, + onClick, + iconColor, + ...rest +}) => { + const handleClick = (e: React.MouseEvent) => { + if (disabled) { + e.preventDefault(); + return; + } + onClick?.(e); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.repeat) { + return; + } + // Space: prevent page scroll on keydown; activate on keyup. + if (e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + return; + } + // Enter activates on keydown. + if (e.key === 'Enter') { + e.preventDefault(); + if (!disabled) { + e.currentTarget.click(); + } + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + if (!disabled) { + e.currentTarget.click(); + } + } + }; + + return ( + )} + > + {commonButtonInterior({ leftIcon, rightIcon, iconColor, size, children })} + + ); +}; + +export default LinkButton; diff --git a/src/components/ui/Status.tsx b/src/components/ui/Status.tsx new file mode 100644 index 0000000000..c99f4071f8 --- /dev/null +++ b/src/components/ui/Status.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import useSWR from 'swr'; +import cn from 'src/utilities/cn'; +import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; + +type StatusProps = { + statusUrl: string; + additionalCSS?: string; + refreshInterval?: number; + showDescription?: boolean; +}; + +export const statusTypes = ['none', 'operational', 'minor', 'major', 'critical', 'unknown'] as const; + +export type StatusType = (typeof statusTypes)[number]; + +export const StatusUrl = 'https://ntqy1wz94gjv.statuspage.io/api/v2/status.json'; + +// Our SWR fetcher function +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +const indicatorClass = (indicator?: StatusType) => { + switch (indicator) { + case 'none': + case 'operational': + return 'bg-gui-success-green'; + case 'minor': + return 'bg-yellow-500'; + case 'major': + return 'bg-orange-500'; + case 'critical': + return 'bg-gui-error-red'; + default: + return 'bg-neutral-500'; + } +}; + +export const StatusIcon = ({ statusUrl, refreshInterval = 1000 * 60 }: StatusProps) => { + const { data, error, isLoading } = useSWR(statusUrl, fetcher, { + refreshInterval, + }); + + return ( + + ); +}; + +const Status = ({ + statusUrl = StatusUrl, + additionalCSS, + refreshInterval = 1000 * 60, + showDescription = false, +}: StatusProps) => { + const { data } = useSWR(statusUrl, fetcher, { + refreshInterval, + }); + + return ( + + + {showDescription && data?.status?.description && ( +
+ + {data.status.description.charAt(0).toUpperCase() + data.status.description.slice(1).toLowerCase()} + + +
+ )} +
+ ); +}; + +export default Status; diff --git a/src/components/ui/colors.ts b/src/components/ui/colors.ts new file mode 100644 index 0000000000..693bff7431 --- /dev/null +++ b/src/components/ui/colors.ts @@ -0,0 +1,182 @@ +export type ColorName = + | (typeof neutralColors)[number] + | (typeof orangeColors)[number] + | (typeof secondaryColors)[number] + | (typeof guiColors)[number] + | (typeof aliasedColors)[number]; + +export const variants = ['', 'dark:'] as const; + +type ColorClassVariants = (typeof variants)[number]; + +export const prefixes = ['text', 'bg', 'from', 'to', 'border'] as const; + +type ColorClassPrefixes = (typeof prefixes)[number]; + +export const colors = ['neutral', 'orange', 'blue', 'yellow', 'green', 'violet', 'pink'] as const; + +export type ColorClassColorGroups = (typeof colors)[number]; + +export type Theme = 'light' | 'dark'; + +export type ColorClass = `${ColorClassVariants}${ColorClassPrefixes}-${ColorName}`; + +export type ColorThemeSet = `${string} dark:${string}`; + +export const neutralColors = [ + 'neutral-000', + 'neutral-100', + 'neutral-200', + 'neutral-300', + 'neutral-400', + 'neutral-500', + 'neutral-600', + 'neutral-700', + 'neutral-800', + 'neutral-900', + 'neutral-1000', + 'neutral-1100', + 'neutral-1200', + 'neutral-1300', +] as const; + +export const orangeColors = [ + 'orange-100', + 'orange-200', + 'orange-300', + 'orange-400', + 'orange-500', + 'orange-600', + 'orange-700', + 'orange-800', + 'orange-900', + 'orange-1000', + 'orange-1100', +] as const; + +export const yellowColors = [ + 'yellow-100', + 'yellow-200', + 'yellow-300', + 'yellow-400', + 'yellow-500', + 'yellow-600', + 'yellow-700', + 'yellow-800', + 'yellow-900', +] as const; + +export const greenColors = [ + 'green-100', + 'green-200', + 'green-300', + 'green-400', + 'green-500', + 'green-600', + 'green-700', + 'green-800', + 'green-900', +] as const; + +export const blueColors = [ + 'blue-100', + 'blue-200', + 'blue-300', + 'blue-400', + 'blue-500', + 'blue-600', + 'blue-700', + 'blue-800', + 'blue-900', +] as const; + +export const violetColors = [ + 'violet-100', + 'violet-200', + 'violet-300', + 'violet-400', + 'violet-500', + 'violet-600', + 'violet-700', + 'violet-800', + 'violet-900', +] as const; + +export const pinkColors = [ + 'pink-100', + 'pink-200', + 'pink-300', + 'pink-400', + 'pink-500', + 'pink-600', + 'pink-700', + 'pink-800', + 'pink-900', +] as const; + +export const secondaryColors = [ + ...yellowColors, + ...greenColors, + ...blueColors, + ...violetColors, + ...pinkColors, +] as const; + +export const guiColors = [ + 'gui-blue-default-light', + 'gui-blue-hover-light', + 'gui-blue-active-light', + 'gui-blue-default-dark', + 'gui-blue-hover-dark', + 'gui-blue-active-dark', + 'gui-blue-focus', + 'gui-disabled-light', + 'gui-disabled-dark', + 'gui-success-green', + 'gui-error-red', + 'gui-focus', + 'gui-focus-outline', + 'gui-visited', +] as const; + +export const aliasedColors = [ + 'white', + 'extra-light-grey', + 'light-grey', + 'mid-grey', + 'dark-grey', + 'charcoal-grey', + 'cool-black', + 'active-orange', + 'bright-red', + 'red-orange', + 'electric-cyan', + 'zingy-green', + 'jazzy-pink', + 'gui-default', + 'gui-hover', + 'gui-active', + 'gui-error', + 'gui-success', + 'gui-default-dark', + 'gui-hover-dark', + 'gui-active-dark', + 'transparent', +] as const; + +export const colorRoles = { + neutral: neutralColors, + orange: orangeColors, + secondary: secondaryColors, + gui: guiColors, +}; + +export const colorGroupLengths = { + neutral: neutralColors.length, + orange: orangeColors.length, + blue: blueColors.length, + yellow: yellowColors.length, + green: greenColors.length, + violet: violetColors.length, + pink: pinkColors.length, +}; diff --git a/src/images/ably-logo.svg b/src/images/ably-logo.svg new file mode 100644 index 0000000000..b8b2895f0e --- /dev/null +++ b/src/images/ably-logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/templates/examples.tsx b/src/templates/examples.tsx index 323d7c7f0b..c8a57573aa 100644 --- a/src/templates/examples.tsx +++ b/src/templates/examples.tsx @@ -1,7 +1,8 @@ import React, { PropsWithChildren, useContext, useEffect, useRef } from 'react'; import Markdown from 'markdown-to-jsx/react'; import { Link } from 'gatsby'; -import LinkButton from '@ably/ui/core/LinkButton'; +import LinkButton from 'src/components/ui/LinkButton'; +import Icon from 'src/components/Icon'; import { UnstyledOpenInCodeSandboxButton } from '@codesandbox/sandpack-react'; import { Head } from '../components/Head'; @@ -12,6 +13,7 @@ import { getApiKey } from 'src/utilities/update-ably-connection-keys'; import ExamplesRenderer from 'src/components/Examples/ExamplesRenderer'; import { LanguageKey } from 'src/data/languages/types'; import { ChevronLeftIcon } from '@heroicons/react/16/solid'; +import { CodeBracketIcon } from '@heroicons/react/24/outline'; const MarkdownOverrides = { h1: { @@ -174,12 +176,17 @@ const Examples = ({ pageContext }: { pageContext: { example: ExampleWithContent href={`https://github.com/ably/docs/tree/main/examples/${example.id}/${activeLanguage}`} target="_blank" variant="secondary" - rightIcon="icon-social-github" + rightIcon={} > View on GitHub - + } + className="w-full" + > View on CodeSandbox diff --git a/yarn.lock b/yarn.lock index 0e94ccc161..f65f611d71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15340,6 +15340,14 @@ swr@^2.4.0: dequal "^2.0.3" use-sync-external-store "^1.6.0" +swr@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.1.tgz#c9e48abff6bf4b04846342e2f1f6be108a078cf6" + integrity sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.6.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"