From 2301befaba0f1299d0f2312d7d3d3d793fea003c Mon Sep 17 00:00:00 2001 From: prism Date: Sat, 3 Jan 2026 18:07:27 +0100 Subject: [PATCH] feat(Components): fix skeleton, add menu, add errorboundary, add alert, add animations, add codeblock (#626) * Components: fix skeleton * Components: add CodeBlock * Components: add Menu * Components: add Alert * Components: fix Menu, add ErrorBoundary * Components: add Animations --- .../components/ComponentsTab.tsx | 40 ++- .../components.dev/components/index.tsx | 103 +++++++- .../components/tabs/AlertTab.tsx | 136 ++++++++++ .../components/tabs/AnimationsTab.tsx | 232 ++++++++++++++++++ .../components/tabs/CodeBlockTab.tsx | 142 +++++++++++ .../components/tabs/ErrorBoundaryTab.tsx | 134 ++++++++++ .../components/tabs/MenuTab.tsx | 224 +++++++++++++++++ .../components/tabs/SkeletonTab.tsx | 33 ++- src/equicordplugins/components.dev/styles.css | 13 +- .../components.dev/types/alert.d.ts | 24 ++ .../components.dev/types/errorboundary.d.ts | 31 +++ .../components.dev/types/index.d.ts | 3 + .../components.dev/types/input.d.ts | 1 + .../components.dev/types/menu.d.ts | 193 +++++++++++++++ .../components.dev/types/skeleton.d.ts | 3 +- 15 files changed, 1290 insertions(+), 22 deletions(-) create mode 100644 src/equicordplugins/components.dev/components/tabs/AlertTab.tsx create mode 100644 src/equicordplugins/components.dev/components/tabs/AnimationsTab.tsx create mode 100644 src/equicordplugins/components.dev/components/tabs/CodeBlockTab.tsx create mode 100644 src/equicordplugins/components.dev/components/tabs/ErrorBoundaryTab.tsx create mode 100644 src/equicordplugins/components.dev/components/tabs/MenuTab.tsx create mode 100644 src/equicordplugins/components.dev/types/alert.d.ts create mode 100644 src/equicordplugins/components.dev/types/errorboundary.d.ts create mode 100644 src/equicordplugins/components.dev/types/menu.d.ts diff --git a/src/equicordplugins/components.dev/components/ComponentsTab.tsx b/src/equicordplugins/components.dev/components/ComponentsTab.tsx index 13f1b160b..4281c9a08 100644 --- a/src/equicordplugins/components.dev/components/ComponentsTab.tsx +++ b/src/equicordplugins/components.dev/components/ComponentsTab.tsx @@ -6,12 +6,16 @@ import "../styles.css"; +import ErrorBoundary from "@components/ErrorBoundary"; import { Heading } from "@components/Heading"; import { SettingsTab, wrapTab } from "@components/settings"; import { Margins } from "@utils/margins"; -import { TabBar, useState } from "@webpack/common"; +import { TabBar, useMemo, useState } from "@webpack/common"; +import { SearchBar } from "."; import AccordionTab from "./tabs/AccordionTab"; +import AlertTab from "./tabs/AlertTab"; +import AnimationsTab from "./tabs/AnimationsTab"; import AnchorTab from "./tabs/AnchorTab"; import AvatarTab from "./tabs/AvatarTab"; import BadgeTab from "./tabs/BadgeTab"; @@ -22,14 +26,17 @@ import CheckboxGroupTab from "./tabs/CheckboxGroupTab"; import CheckboxTab from "./tabs/CheckboxTab"; import ChipTab from "./tabs/ChipTab"; import ClickableTab from "./tabs/ClickableTab"; +import CodeBlockTab from "./tabs/CodeBlockTab"; import ColorPickerTab from "./tabs/ColorPickerTab"; import ComboboxTab from "./tabs/ComboboxTab"; import DividerTab from "./tabs/DividerTab"; +import ErrorBoundaryTab from "./tabs/ErrorBoundaryTab"; import FocusLockTab from "./tabs/FocusLockTab"; import GuildIconTab from "./tabs/GuildIconTab"; import HeadingTab from "./tabs/HeadingTab"; import LayerModalTab from "./tabs/LayerModalTab"; import ListboxTab from "./tabs/ListboxTab"; +import MenuTab from "./tabs/MenuTab"; import ModalTab from "./tabs/ModalTab"; import NoticeTab from "./tabs/NoticeTab"; import PaginatorTab from "./tabs/PaginatorTab"; @@ -57,6 +64,8 @@ import UserSummaryItemTab from "./tabs/UserSummaryItemTab"; const TABS = [ { id: "accordion", label: "Accordion" }, + { id: "alert", label: "Alert" }, + { id: "animations", label: "Animations" }, { id: "anchor", label: "Anchor" }, { id: "avatar", label: "Avatar" }, { id: "badge", label: "Badge" }, @@ -67,14 +76,17 @@ const TABS = [ { id: "checkbox", label: "Checkbox" }, { id: "checkboxgroup", label: "CheckboxGroup" }, { id: "clickable", label: "Clickable" }, + { id: "codeblock", label: "CodeBlock" }, { id: "colorpicker", label: "ColorPicker" }, { id: "combobox", label: "Combobox" }, { id: "divider", label: "Divider" }, + { id: "errorboundary", label: "ErrorBoundary" }, { id: "focuslock", label: "FocusLock" }, { id: "guildicon", label: "GuildIcon" }, { id: "heading", label: "Heading" }, { id: "layermodal", label: "LayerModal" }, { id: "listbox", label: "Listbox" }, + { id: "menu", label: "Menu" }, { id: "modal", label: "Modal" }, { id: "notice", label: "Notice" }, { id: "paginator", label: "Paginator" }, @@ -106,6 +118,8 @@ type TabId = typeof TABS[number]["id"]; const TAB_COMPONENTS: Record = { accordion: AccordionTab, + alert: AlertTab, + animations: AnimationsTab, anchor: AnchorTab, avatar: AvatarTab, badge: BadgeTab, @@ -116,14 +130,17 @@ const TAB_COMPONENTS: Record = { checkbox: CheckboxTab, checkboxgroup: CheckboxGroupTab, clickable: ClickableTab, + codeblock: CodeBlockTab, colorpicker: ColorPickerTab, combobox: ComboboxTab, divider: DividerTab, + errorboundary: ErrorBoundaryTab, focuslock: FocusLockTab, guildicon: GuildIconTab, heading: HeadingTab, layermodal: LayerModalTab, listbox: ListboxTab, + menu: MenuTab, modal: ModalTab, notice: NoticeTab, paginator: PaginatorTab, @@ -152,11 +169,26 @@ const TAB_COMPONENTS: Record = { function ComponentsTab() { const [currentTab, setCurrentTab] = useState("avatar"); + const [search, setSearch] = useState(""); const TabComponent = TAB_COMPONENTS[currentTab]; + const filteredTabs = useMemo(() => { + if (!search.trim()) return TABS; + const query = search.toLowerCase(); + return TABS.filter(tab => tab.label.toLowerCase().includes(query)); + }, [search]); + return ( Components +
+ setSearch("")} + placeholder="Search components..." + /> +
- {TABS.map(tab => ( + {filteredTabs.map(tab => ( {tab.label} ))}
- + + +
); diff --git a/src/equicordplugins/components.dev/components/index.tsx b/src/equicordplugins/components.dev/components/index.tsx index 8d84f22d1..c239b3b13 100644 --- a/src/equicordplugins/components.dev/components/index.tsx +++ b/src/equicordplugins/components.dev/components/index.tsx @@ -10,7 +10,7 @@ import { Heading } from "@components/Heading"; import { Paragraph } from "@components/Paragraph"; import { TooltipContainer } from "@components/TooltipContainer"; import { proxyLazy } from "@utils/lazy"; -import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findLazy, mapMangledModuleLazy } from "@webpack"; +import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findLazy, mapMangledModuleLazy, waitFor, wreq } from "@webpack"; import { GuildStore, ListScrollerAuto, @@ -31,6 +31,7 @@ import { import { ToastPosition, ToastType } from "../constants"; import type { AccordionProps, + AlertsType, AnchorProps, AvatarProps, BadgeShapesType, @@ -41,14 +42,17 @@ import type { ColorPickerWithSwatchesProps, ColorSwatchProps, ConfirmModalProps, + ContextMenuApiType, CustomColorButtonProps, DefaultColorButtonProps, DiscordHeadingProps, DiscordTextProps, + ErrorBoundaryProps, ExpressiveModalProps, FocusLockProps, GuildIconProps, IconBadgeProps, + LocalErrorBoundaryProps, ManaBaseRadioGroupProps, ManaButtonProps, ManaCalendarProps, @@ -65,6 +69,7 @@ import type { ManaTextButtonProps, ManaTextInputProps, ManaTooltipProps, + MenuType, ModalProps, ModalRenderFn, NoticeProps, @@ -74,6 +79,7 @@ import type { PopoutComponent, ProgressBarProps, SearchBarProps, + SimpleErrorBoundaryProps, SkeletonProps, SliderProps, SpinnerComponent, @@ -141,7 +147,7 @@ export const SearchBar = findComponentByCodeLazy("#{intl::SEARCH}", "clearable", export const Paginator = findComponentByCodeLazy("#{intl::BACK}", "#{intl::NEXT}", "renderPageWrapper") as React.ComponentType; export const Notice = findComponentByCodeLazy("messageType", "iconDiv", "actionContainer") as React.ComponentType; export const Chip = findComponentByCodeLazy('variant:"eyebrow"', "chip,") as React.ComponentType; -export const Skeleton = findComponentByCodeLazy("productSkeletonCardContainer", "skipPulseAnimation") as React.ComponentType; +export const Skeleton = findComponentByCodeLazy("withHeader:t=!0,size:") as React.ComponentType; export const Accordion = findComponentByCodeLazy("accordionContainer", "onExpandedChange", "defaultExpanded") as React.ComponentType; export const Timestamp = findComponentByCodeLazy("#{intl::MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL}", "isVisibleOnlyOnHover") as React.ComponentType; export const GuildIcon = findComponentByCodeLazy("Masks.CLAN_ICON", "guildIconImage") as React.ComponentType; @@ -150,10 +156,20 @@ export const Popout: PopoutComponent = proxyLazy(() => findLazy(m => m?.y?.Anima export const FocusLock = findComponentByCodeLazy(".containerRef,{keyboardModeEnabled:") as React.ComponentType; export const UserSummaryItem = findComponentByCodeLazy("popoutUserId") as React.ComponentType; -export const Animations = mapMangledModuleLazy(".assign({colorNames:", { - Transition: filters.componentByCode('["items","children"]', ",null,"), - animated: filters.byProps("div", "text") -}); +export const Animations = findByPropsLazy("useSpring", "animated", "useTransition"); + +export const SpringConfigs = proxyLazy(() => Animations.config) as { + default: object; + gentle: object; + wobbly: object; + stiff: object; + slow: object; + molasses: object; +}; + +export const useSpring = proxyLazy(() => Animations.useSpring) as (config: object) => object; +export const useTransition = proxyLazy(() => Animations.useTransition) as (items: T[], config: object) => (callback: (style: object, item: T) => React.ReactNode) => React.ReactNode[]; +export const useTrail = proxyLazy(() => Animations.useTrail) as (count: number, config: object) => object[]; const ColorPickerModule = findLazy(m => { const values = Object.values(m); @@ -263,3 +279,78 @@ export function DiscordText(props: DiscordTextProps) { export function Divider({ className, style, ...restProps }: React.ComponentPropsWithoutRef<"hr">) { return
; } + +const CodeContainerClasses = findByPropsLazy("markup", "codeContainer"); + +export function InlineCode({ children }: { children: React.ReactNode; }) { + return ( + + {children} + + ); +} + +const AlertsModule = findByPropsLazy("show", "close", "confirm"); + +export const Alerts: AlertsType = { + show: options => AlertsModule.show(options), + close: () => AlertsModule.close(), + confirm: options => AlertsModule.confirm(options), +}; + +const MenuComponents: Record> = {}; + +waitFor(m => m.name === "MenuCheckboxItem", (_, id) => { + const exports = wreq(id); + for (const key in exports) { + try { + const value = exports[key]; + if (typeof value === "function" && value.name?.startsWith("Menu")) { + MenuComponents[value.name] = value; + } + } catch { } + } +}); + +waitFor(filters.componentByCode('path:["empty"]'), m => { MenuComponents.Menu = m; }); +waitFor(filters.componentByCode("sliderContainer", "slider", "handleSize:16", "=100"), m => { MenuComponents.MenuSliderControl = m; }); +waitFor(filters.componentByCode(".SEARCH)", ".focus()", "query:"), m => { MenuComponents.MenuSearchControl = m; }); + +waitFor(m => m.name === "MenuCheckboxItem", (_, id) => { + const exports = wreq(id); + for (const key in exports) { + try { + const value = exports[key]; + if (typeof value !== "function") continue; + const str = value.toString(); + if (str.length === 26 && str.endsWith("(e){return null}") && value.name === "l") { + Object.defineProperty(value, "name", { value: "MenuSwitchItem" }); + MenuComponents.MenuSwitchItem = value; + break; + } + } catch { } + } +}); + +export const Menu: MenuType = proxyLazy(() => ({ + Menu: MenuComponents.Menu, + MenuItem: MenuComponents.MenuItem, + MenuCheckboxItem: MenuComponents.MenuCheckboxItem, + MenuRadioItem: MenuComponents.MenuRadioItem, + MenuSwitchItem: MenuComponents.MenuSwitchItem, + MenuGroup: MenuComponents.MenuGroup, + MenuSeparator: MenuComponents.MenuSeparator, + MenuControlItem: MenuComponents.MenuControlItem, + MenuSliderControl: MenuComponents.MenuSliderControl, + MenuSearchControl: MenuComponents.MenuSearchControl, +})); + +export const ContextMenuApi: ContextMenuApiType = mapMangledModuleLazy('type:"CONTEXT_MENU_OPEN', { + closeContextMenu: filters.byCode("CONTEXT_MENU_CLOSE"), + openContextMenu: filters.byCode("renderLazy:"), + openContextMenuLazy: e => typeof e === "function" && e.toString().length < 100 +}); + +export const ErrorBoundary = findComponentByCodeLazy("this.resetErrorBoundary", "onReset", "FallbackComponent") as React.ComponentType; +export const SimpleErrorBoundary = findComponentByCodeLazy("getDerivedStateFromError", "this.props.fallback") as React.ComponentType; +export const LocalErrorBoundary = findComponentByCodeLazy("LocalErrorBoundary", "text-feedback-critical") as React.ComponentType; diff --git a/src/equicordplugins/components.dev/components/tabs/AlertTab.tsx b/src/equicordplugins/components.dev/components/tabs/AlertTab.tsx new file mode 100644 index 000000000..b049a509c --- /dev/null +++ b/src/equicordplugins/components.dev/components/tabs/AlertTab.tsx @@ -0,0 +1,136 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Alerts, Button, Paragraph, showToast, ToastType } from ".."; +import { SectionWrapper } from "../SectionWrapper"; + +export default function AlertTab() { + const showBasicAlert = () => { + Alerts.show({ + title: "Basic Alert", + body: "This is a basic alert dialog with just an OK button.", + }); + }; + + const showConfirmAlert = () => { + Alerts.show({ + title: "Confirm Action", + body: "Are you sure you want to proceed with this action?", + confirmText: "Yes, proceed", + cancelText: "Cancel", + onConfirm: () => showToast("Confirmed!", ToastType.SUCCESS), + onCancel: () => showToast("Cancelled", ToastType.MESSAGE), + }); + }; + + const showDangerAlert = () => { + Alerts.show({ + title: "Delete Item", + body: "This action cannot be undone. Are you sure you want to delete this item?", + confirmText: "Delete", + confirmVariant: "critical-primary", + cancelText: "Keep it", + onConfirm: () => showToast("Deleted!", ToastType.SUCCESS), + }); + }; + + const showSecondaryConfirmAlert = () => { + Alerts.show({ + title: "Multiple Actions", + body: "This alert has a secondary confirm action.", + confirmText: "Primary Action", + secondaryConfirmText: "Secondary Action", + cancelText: "Cancel", + onConfirm: () => showToast("Primary action!", ToastType.SUCCESS), + onConfirmSecondary: () => showToast("Secondary action!", ToastType.MESSAGE), + }); + }; + + return ( +
+ + + Simple alert with just a title and body. + + + + + + + Alert with confirm and cancel buttons and callbacks. + + + + + + + Alert with red danger button for destructive actions. + + + + + + + Alert with multiple confirm actions. + + + + + + + Alerts.show(options) - Show an alert dialog + + + Alerts.close() - Close the current alert + + + Alerts.confirm(options) - Show a confirm dialog + + + + + + title - Alert title text + + + body - Alert body content (string or ReactNode) + + + confirmText - Text for confirm button + + + cancelText - Text for cancel button (shows cancel if set) + + + confirmVariant - "primary", "critical-primary", or "expressive" + + + secondaryConfirmText - Text for secondary action button + + + onConfirm - Callback when confirm clicked + + + onConfirmSecondary - Callback for secondary action + + + onCancel - Callback when cancel clicked + + + onCloseCallback - Callback when alert closes + + +
+ ); +} diff --git a/src/equicordplugins/components.dev/components/tabs/AnimationsTab.tsx b/src/equicordplugins/components.dev/components/tabs/AnimationsTab.tsx new file mode 100644 index 000000000..1cb8ae335 --- /dev/null +++ b/src/equicordplugins/components.dev/components/tabs/AnimationsTab.tsx @@ -0,0 +1,232 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Animations, ManaButton, Paragraph, SpringConfigs, useSpring, useState, useTrail, useTransition } from ".."; +import { SectionWrapper } from "../SectionWrapper"; + +const { animated } = Animations; + +const TRAIL_ITEMS = ["Discord", "uses", "react-spring", "for", "animations"]; + +function SpringDemo() { + const [toggled, setToggled] = useState(false); + const [config, setConfig] = useState("default"); + + const spring = useSpring({ + opacity: toggled ? 1 : 0.3, + transform: toggled ? "scale(1) rotate(0deg)" : "scale(0.8) rotate(-10deg)", + backgroundColor: toggled ? "var(--brand-500)" : "var(--background-tertiary)", + config: SpringConfigs[config] + }); + + return ( + + + Animate values with spring physics. Click the box or change config. + +
+ {(Object.keys(SpringConfigs) as (keyof typeof SpringConfigs)[]).map(c => ( + setConfig(c)} + /> + ))} +
+ setToggled(!toggled)} + style={{ + ...spring, + width: 120, + height: 120, + borderRadius: 12, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center" + }} + > + Click me + +
+ ); +} + +function TransitionDemo() { + const [items, setItems] = useState([1, 2, 3]); + const [nextId, setNextId] = useState(4); + + const transitions = useTransition(items, { + from: { opacity: 0, transform: "translateX(-20px) scale(0.9)" }, + enter: { opacity: 1, transform: "translateX(0px) scale(1)" }, + leave: { opacity: 0, transform: "translateX(20px) scale(0.9)" }, + keys: item => item, + config: SpringConfigs.gentle + }); + + const addItem = () => { + setItems([...items, nextId]); + setNextId(nextId + 1); + }; + + const removeItem = (id: number) => { + setItems(items.filter(i => i !== id)); + }; + + return ( + + + Animate items entering and leaving a list. + +
+ + setItems([])} + disabled={items.length === 0} + /> +
+
+ {transitions((style, item) => ( + removeItem(item)} + > + Item {item} + + ))} +
+
+ ); +} + +function TrailDemo() { + const [show, setShow] = useState(true); + + const trail = useTrail(TRAIL_ITEMS.length, { + opacity: show ? 1 : 0, + transform: show ? "translateY(0px)" : "translateY(20px)", + config: SpringConfigs.gentle + }) as object[]; + + return ( + + + Staggered animations for multiple items. Each item animates slightly after the previous. + +
+ setShow(!show)} + /> +
+
+ {trail.map((style, i) => ( + + {TRAIL_ITEMS[i]} + + ))} +
+
+ ); +} + +function AnimatedElementsDemo() { + const [hover, setHover] = useState(false); + + const hoverSpring = useSpring({ + scale: hover ? 1.05 : 1, + boxShadow: hover + ? "0 8px 24px rgba(0,0,0,0.3)" + : "0 2px 8px rgba(0,0,0,0.1)", + config: SpringConfigs.wobbly + }); + + return ( + + + Use animated.div, animated.span, animated.svg, etc. for spring-animated elements. + +
+ setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + ...hoverSpring, + width: 100, + height: 100, + background: "var(--background-secondary)", + borderRadius: 12, + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer" + }} + > + Hover me + +
+
+ ); +} + +export default function AnimationsTab() { + return ( +
+ + + + + + + + Preset spring configurations for different animation feels. + + • default - Standard spring + • gentle - Soft, slow spring + • wobbly - Bouncy, playful spring + • stiff - Quick, snappy spring + • slow - Very slow spring + • molasses - Extremely slow spring + + + + • useSpring - Single spring animation + • useSprings - Multiple springs + • useTransition - Enter/leave animations + • useTrail - Staggered animations + • useChain - Sequence animations + + + + • Transition - Declarative transitions + • Spring - Declarative spring + • Trail - Declarative trail + • animated.* - Animated HTML/SVG elements + +
+ ); +} diff --git a/src/equicordplugins/components.dev/components/tabs/CodeBlockTab.tsx b/src/equicordplugins/components.dev/components/tabs/CodeBlockTab.tsx new file mode 100644 index 000000000..9214495a4 --- /dev/null +++ b/src/equicordplugins/components.dev/components/tabs/CodeBlockTab.tsx @@ -0,0 +1,142 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByPropsLazy } from "@webpack"; +import { Parser } from "@webpack/common"; + +import { Paragraph } from ".."; +import { SectionWrapper } from "../SectionWrapper"; + +const MarkupClasses = findByPropsLazy("markup", "codeContainer"); + +interface CodeBlockProps { + content: string; + language?: string; +} + +function CodeBlock({ content, language = "" }: CodeBlockProps) { + const codeBlock = `\`\`\`${language}\n${content}\n\`\`\``; + return ( +
+ {Parser.parse(codeBlock)} +
+ ); +} + +function InlineCode({ children }: { children: React.ReactNode; }) { + return ( + + {children} + + ); +} + +const JS_EXAMPLE = `function greet(name) { + return \`Hello, \${name}!\`; +} + +console.log(greet("World"));`; + +const TS_EXAMPLE = `interface User { + id: string; + name: string; + email?: string; +} + +const getUser = (id: string): User => { + return { id, name: "John" }; +};`; + +const CSS_EXAMPLE = `.container { + display: flex; + gap: 8px; + padding: 16px; + background: var(--background-secondary); + border-radius: 8px; +}`; + +const PYTHON_EXAMPLE = `def fibonacci(n: int) -> list[int]: + if n <= 0: + return [] + sequence = [0, 1] + while len(sequence) < n: + sequence.append(sequence[-1] + sequence[-2]) + return sequence[:n] + +print(fibonacci(10))`; + +const JSON_EXAMPLE = `{ + "name": "equicord", + "version": "1.0.0", + "plugins": ["example"], + "settings": { + "enabled": true, + "theme": "dark" + } +}`; + +const BASH_EXAMPLE = `#!/bin/bash +for file in *.ts; do + echo "Processing $file" + node "$file" +done`; + +const PLAIN_EXAMPLE = `This is a plain code block +without syntax highlighting. +It preserves whitespace and formatting.`; + +export default function CodeBlockTab() { + return ( +
+ + + Use InlineCode component for inline code snippets. + + I really like cats so yeah + Use console.log() to debug your code. + + Combine const with let and + use === for strict equality. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Use Parser.parse() from @webpack/common to + render markdown with code blocks. Wrap in markup class for proper styling. + + +
+ ); +} diff --git a/src/equicordplugins/components.dev/components/tabs/ErrorBoundaryTab.tsx b/src/equicordplugins/components.dev/components/tabs/ErrorBoundaryTab.tsx new file mode 100644 index 000000000..312bdac4c --- /dev/null +++ b/src/equicordplugins/components.dev/components/tabs/ErrorBoundaryTab.tsx @@ -0,0 +1,134 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button, ErrorBoundary, LocalErrorBoundary, Paragraph, SimpleErrorBoundary, useState } from ".."; +import { SectionWrapper } from "../SectionWrapper"; + +function ThrowError(): never { + throw new Error("This is a demo error thrown intentionally!"); +} + +function ErrorTrigger({ label }: { label: string; }) { + const [shouldThrow, setShouldThrow] = useState(false); + + if (shouldThrow) { + return ; + } + + return ( + + ); +} + +function CustomFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void; }) { + return ( +
+ + Custom Fallback UI + + + Error: {error.message} + + +
+ ); +} + +export default function ErrorBoundaryTab() { + const [key1, setKey1] = useState(0); + const [key2, setKey2] = useState(0); + const [key3, setKey3] = useState(0); + + return ( +
+ + + Full-featured error boundary with fallbackRender, FallbackComponent, onError, onReset, and resetKeys props. + +
+ ( + + )} + onError={(error, info) => console.log("ErrorBoundary caught:", error, info)} + onReset={() => setKey1(k => k + 1)} + > + + +
+
+ + + + Simple error boundary with just a fallback prop. Shows fallback content when error occurs. + +
+ + Something went wrong! + +
+ } + > + + +
+ + + + + Displays an inline error message. Used for non-critical component errors. + +
+ + + + +
+
+ + + + ErrorBoundary + + + fallback - Static ReactNode to show on error. + + + fallbackRender - Function receiving error and resetErrorBoundary. + + + FallbackComponent - Component receiving error and resetErrorBoundary props. + + + onError - Callback when error is caught (error, errorInfo). + + + onReset - Callback when boundary is reset. + + + resetKeys - Array of values that trigger reset when changed. + + + + + + Wrap components that may throw errors. Use ErrorBoundary for full control, + SimpleErrorBoundary for basic fallback, or LocalErrorBoundary for inline error display. + + + + ); +} diff --git a/src/equicordplugins/components.dev/components/tabs/MenuTab.tsx b/src/equicordplugins/components.dev/components/tabs/MenuTab.tsx new file mode 100644 index 000000000..57ced22be --- /dev/null +++ b/src/equicordplugins/components.dev/components/tabs/MenuTab.tsx @@ -0,0 +1,224 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button, ContextMenuApi, Menu, Paragraph, useState } from ".."; +import { SectionWrapper } from "../SectionWrapper"; + +function DemoMenu({ onClose }: { onClose: () => void; }) { + const [checkboxValue, setCheckboxValue] = useState(false); + const [switchValue, setSwitchValue] = useState(true); + const [radioValue, setRadioValue] = useState("option1"); + const [sliderValue, setSliderValue] = useState(50); + + return ( + + console.log("Basic item clicked")} + /> + ( + + + + )} + action={() => console.log("Icon item clicked")} + /> + console.log("Hint item clicked")} + /> + console.log("Subtext item clicked")} + /> + + + + + + { }} /> + { }} /> + { }} /> + { }} /> + { }} /> + { }} /> + + + + + setCheckboxValue(v => !v)} + /> + setCheckboxValue(v => !v)} + /> + + + + setSwitchValue(v => !v)} + /> + + + + + setRadioValue("option1")} + /> + setRadioValue("option2")} + /> + setRadioValue("option3")} + /> + + + + + + console.log("Sub item 1")} + /> + console.log("Sub item 2")} + /> + + console.log("Nested")} + /> + + + + + + ( + `${v}%`} + /> + )} + /> + + ); +} + +export default function MenuTab() { + const openMenu = (e: React.MouseEvent) => { + ContextMenuApi.openContextMenu(e, () => ( + + )); + }; + + return ( +
+ + + Click or right-click the button to open a demo context menu. + +
+ + +
+
+ + + + Menu.Menu - Root container with navId and onClose. + + + Menu.MenuItem - Basic item with id, label, action, icon, hint, subtext, disabled, color. + + + Menu.MenuCheckboxItem - Toggleable checkbox item with checked state. + + + Menu.MenuRadioItem - Radio selection with group prop for mutual exclusion. + + + Menu.MenuSwitchItem - Toggle switch item with checked state. + + + Menu.MenuGroup - Groups items with optional label header. + + + Menu.MenuSeparator - Visual divider between items. + + + Menu.MenuControlItem - Wrapper for custom controls like sliders. + + + Menu.MenuSliderControl - Slider control inside MenuControlItem. + + + Menu.MenuSearchControl - Search input for filtering menu items. + + + + + + Available colors: default, brand, danger, success, premium, premium-gradient. + + + + + + Use ContextMenuApi.openContextMenu(event, () => <Menu />) to open menus. + For patches, use NavContextMenuPatchCallback from @api/ContextMenu. + + +
+ ); +} diff --git a/src/equicordplugins/components.dev/components/tabs/SkeletonTab.tsx b/src/equicordplugins/components.dev/components/tabs/SkeletonTab.tsx index 1e991f7a8..a2674042d 100644 --- a/src/equicordplugins/components.dev/components/tabs/SkeletonTab.tsx +++ b/src/equicordplugins/components.dev/components/tabs/SkeletonTab.tsx @@ -8,27 +8,40 @@ import { ManaButton, Paragraph, Skeleton, useState } from ".."; import { SectionWrapper } from "../SectionWrapper"; export default function SkeletonTab() { - const [animated, setAnimated] = useState(true); + const [withHeader, setWithHeader] = useState(true); + const [size, setSize] = useState(5); return (
- Loading placeholder with pulse animation. Used in Discord's shop for product cards. + Loading placeholder with shimmer animation. Used in Discord's member list loading state. - setAnimated(!animated)} - style={{ marginBottom: 16 }} - /> +
+ setWithHeader(!withHeader)} + /> + setSize(Math.max(1, size - 1))} + /> + setSize(Math.min(20, size + 1))} + /> +
- +
- • skipPulseAnimation?: boolean - Disable pulse animation + • withHeader?: boolean - Show header placeholder (default: true) + • size?: number - Number of skeleton rows to display (default: 15)
); diff --git a/src/equicordplugins/components.dev/styles.css b/src/equicordplugins/components.dev/styles.css index fadb6a224..bd5a919bc 100644 --- a/src/equicordplugins/components.dev/styles.css +++ b/src/equicordplugins/components.dev/styles.css @@ -1,10 +1,14 @@ +.vc-compfinder-search { + margin-bottom: 40px; +} + .vc-compfinder-tabbar { margin-bottom: 16px; flex-wrap: wrap !important; gap: 8px 16px; } -.vc-compfinder-tabbar > div { +.vc-compfinder-tabbar>div { flex-wrap: wrap !important; gap: 8px 16px; } @@ -86,4 +90,9 @@ display: flex; flex-direction: row-reverse; gap: 8px; -} \ No newline at end of file +} + +.vc-alert-danger-btn { + color: var(--white-500); + background-color: var(--button-danger-background); +} diff --git a/src/equicordplugins/components.dev/types/alert.d.ts b/src/equicordplugins/components.dev/types/alert.d.ts new file mode 100644 index 000000000..df08a5d47 --- /dev/null +++ b/src/equicordplugins/components.dev/types/alert.d.ts @@ -0,0 +1,24 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export interface AlertOptions { + title: string; + body: React.ReactNode; + confirmText?: string; + cancelText?: string; + secondaryConfirmText?: string; + confirmVariant?: "primary" | "critical-primary" | "expressive"; + onConfirm?: () => void; + onCancel?: () => void; + onConfirmSecondary?: () => void; + onCloseCallback?: () => void; +} + +export interface AlertsType { + show: (options: AlertOptions) => void; + close: () => void; + confirm: (options: AlertOptions) => Promise; +} diff --git a/src/equicordplugins/components.dev/types/errorboundary.d.ts b/src/equicordplugins/components.dev/types/errorboundary.d.ts new file mode 100644 index 000000000..3a205d708 --- /dev/null +++ b/src/equicordplugins/components.dev/types/errorboundary.d.ts @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ComponentType, ErrorInfo, ReactNode } from "react"; + +export interface ErrorBoundaryFallbackProps { + error: Error; + resetErrorBoundary: (...args: any[]) => void; +} + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + fallbackRender?: (props: ErrorBoundaryFallbackProps) => ReactNode; + FallbackComponent?: ComponentType; + onError?: (error: Error, info: ErrorInfo) => void; + onReset?: (details: { reason: "imperative-api" | "keys"; args?: any[]; prev?: any[]; next?: any[]; }) => void; + resetKeys?: any[]; +} + +export interface SimpleErrorBoundaryProps { + children: ReactNode; + fallback: ReactNode; +} + +export interface LocalErrorBoundaryProps { + children: ReactNode; +} diff --git a/src/equicordplugins/components.dev/types/index.d.ts b/src/equicordplugins/components.dev/types/index.d.ts index 0fb9bbe57..e5dcf1b48 100644 --- a/src/equicordplugins/components.dev/types/index.d.ts +++ b/src/equicordplugins/components.dev/types/index.d.ts @@ -6,6 +6,7 @@ export * from "../constants"; export * from "./accordion"; +export * from "./alert"; export * from "./anchor"; export * from "./avatar"; export * from "./badge"; @@ -15,10 +16,12 @@ export * from "./checkbox"; export * from "./chip"; export * from "./clickable"; export * from "./colorpicker"; +export * from "./errorboundary"; export * from "./focuslock"; export * from "./guildicon.d"; export * from "./heading"; export * from "./input"; +export * from "./menu"; export * from "./modal"; export * from "./notice"; export * from "./paginator"; diff --git a/src/equicordplugins/components.dev/types/input.d.ts b/src/equicordplugins/components.dev/types/input.d.ts index 6c3882542..2fbccadad 100644 --- a/src/equicordplugins/components.dev/types/input.d.ts +++ b/src/equicordplugins/components.dev/types/input.d.ts @@ -92,5 +92,6 @@ export interface SearchBarProps { onFocus?: () => void; autoComplete?: string; inputProps?: Record; + className?: string; "aria-label"?: string; } diff --git a/src/equicordplugins/components.dev/types/menu.d.ts b/src/equicordplugins/components.dev/types/menu.d.ts new file mode 100644 index 000000000..035e6ad76 --- /dev/null +++ b/src/equicordplugins/components.dev/types/menu.d.ts @@ -0,0 +1,193 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ComponentType, CSSProperties, MouseEvent, ReactNode, Ref, UIEvent } from "react"; + +export type MenuItemColor = "default" | "brand" | "danger" | "premium" | "premium-gradient" | "success"; + +export type MenuIconSize = "sm" | "md" | "lg"; + +export interface MenuProps { + navId: string; + onClose: () => void; + onSelect?: () => void; + onInteraction?: (data: { type: number; }) => void; + variant?: "flexible" | "fixed"; + hideScroller?: boolean; + className?: string; + style?: CSSProperties; + "aria-label"?: string; + children: ReactNode; +} + +export interface MenuItemIconProps { + color?: string; + className?: string; +} + +export interface MenuItemProps { + id: string; + label: ReactNode | ((props: MenuItemProps) => ReactNode); + color?: MenuItemColor; + icon?: ComponentType; + iconLeft?: ComponentType; + iconLeftSize?: MenuIconSize; + iconProps?: MenuItemIconProps; + hint?: ReactNode | ((props: MenuItemProps) => ReactNode); + subtext?: ReactNode; + subtextLineClamp?: number; + hasSubmenu?: boolean; + disabled?: boolean; + action?: (e: MouseEvent) => void; + dontCloseOnAction?: boolean; + dontCloseOnActionIfHoldingShiftKey?: boolean; + className?: string; + focusedClassName?: string; + subMenuIconClassName?: string; + render?: (props: MenuItemProps) => ReactNode; + navigable?: boolean; + onChildrenScroll?: () => void; + childRowHeight?: number; + listClassName?: string; + subMenuClassName?: string; + children?: ReactNode; +} + +export interface MenuCheckboxItemProps { + id: string; + label: ReactNode | ((props: MenuCheckboxItemProps) => ReactNode); + color?: MenuItemColor; + checked: boolean; + action?: (e: MouseEvent) => void; + disabled?: boolean; + subtext?: ReactNode; + className?: string; + focusedClassName?: string; +} + +export interface MenuRadioItemProps { + id: string; + group: string; + label: ReactNode | ((props: MenuRadioItemProps) => ReactNode); + color?: MenuItemColor; + checked: boolean; + action?: (e: MouseEvent) => void; + disabled?: boolean; + subtext?: ReactNode; +} + +export interface MenuSwitchItemProps { + id: string; + label?: ReactNode; + color?: MenuItemColor; + checked: boolean; + action: (e: MouseEvent) => void; + disabled?: boolean; + className?: string; +} + +export interface MenuGroupProps { + label?: ReactNode; + color?: MenuItemColor; + className?: string; + children: ReactNode; +} + +export interface MenuCustomItemRenderProps { + color: MenuItemColor; + disabled: boolean; + isFocused: boolean; +} + +export interface MenuCustomItemProps { + id: string; + color?: MenuItemColor; + disabled?: boolean; + keepItemStyles?: boolean; + action?: (e: MouseEvent) => void; + dontCloseOnAction?: boolean; + dontCloseOnActionIfHoldingShiftKey?: boolean; + navigable?: boolean; + children: (props: MenuCustomItemRenderProps) => ReactNode; +} + +export interface MenuControlItemControlProps { + onClose: () => void; + disabled?: boolean; + isFocused: boolean; + onInteraction: (type?: number) => void; +} + +export interface MenuControlItemProps { + id: string; + label?: ReactNode; + color?: MenuItemColor; + control: (props: MenuControlItemControlProps, ref: Ref) => ReactNode; + disabled?: boolean; + showDefaultFocus?: boolean; +} + +export interface MenuCompositeControlItemProps { + id: string; + color?: MenuItemColor; + disabled?: boolean; + showDefaultFocus?: boolean; + interactive?: boolean; + children: ReactNode; +} + +export interface MenuControlRef { + focus: () => void; + blur: () => void; + activate: () => boolean; +} + +export interface MenuSliderControlProps { + value: number; + minValue?: number; + maxValue?: number; + onChange: (value: number) => void; + renderValue?: (value: number) => string; + "aria-label"?: string; +} + +export interface MenuSearchControlProps { + query: string; + onChange: (query: string) => void; + placeholder?: string; + "aria-label"?: string; +} + +export interface MenuType { + Menu: ComponentType; + MenuItem: ComponentType; + MenuCheckboxItem: ComponentType; + MenuRadioItem: ComponentType; + MenuSwitchItem: ComponentType; + MenuGroup: ComponentType; + MenuSeparator: ComponentType>; + MenuControlItem: ComponentType; + MenuSliderControl: ComponentType; + MenuSearchControl: ComponentType; +} + +export interface ContextMenuOpenOptions { + enableSpellCheck?: boolean; +} + +export interface ContextMenuApiType { + openContextMenu: ( + event: UIEvent, + render: (props: { onClose: () => void; }) => ReactNode, + options?: ContextMenuOpenOptions + ) => void; + openContextMenuLazy: ( + event: UIEvent, + renderLazy: () => Promise<(props: { onClose: () => void; }) => ReactNode>, + options?: ContextMenuOpenOptions + ) => void; + closeContextMenu: (callback?: () => void) => void; +} diff --git a/src/equicordplugins/components.dev/types/skeleton.d.ts b/src/equicordplugins/components.dev/types/skeleton.d.ts index a724aba69..e68a44df3 100644 --- a/src/equicordplugins/components.dev/types/skeleton.d.ts +++ b/src/equicordplugins/components.dev/types/skeleton.d.ts @@ -5,5 +5,6 @@ */ export interface SkeletonProps { - skipPulseAnimation?: boolean; + withHeader?: boolean; + size?: number; }