From a23880eaec3817a252b1f189885b1182a225ba82 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 17 Mar 2026 08:52:53 +0300 Subject: [PATCH] Implement SplitButton from Material 3 Expressive --- .../src/core/components/DownloadDialog.tsx | 113 ++++++++ frontend/src/core/components/SplitButton.tsx | 266 ++++++++++++++++++ .../css/download-dialog.module.scss | 114 ++++++++ .../components/css/split-button.module.scss | 227 +++++++++++++++ frontend/src/images/linux.svg | 18 ++ frontend/src/images/mac.svg | 4 + frontend/src/images/windows.svg | 6 + frontend/src/pages/home/HomeFooter.tsx | 2 +- frontend/src/pages/home/HomePage.tsx | 90 ++++-- frontend/src/pages/home/home.module.scss | 15 + frontend/src/utils/material.tsx | 68 ++++- frontend/src/vite-env.d.ts | 6 + 12 files changed, 904 insertions(+), 25 deletions(-) create mode 100644 frontend/src/core/components/DownloadDialog.tsx create mode 100644 frontend/src/core/components/SplitButton.tsx create mode 100644 frontend/src/core/components/css/download-dialog.module.scss create mode 100644 frontend/src/core/components/css/split-button.module.scss create mode 100644 frontend/src/images/linux.svg create mode 100644 frontend/src/images/mac.svg create mode 100644 frontend/src/images/windows.svg diff --git a/frontend/src/core/components/DownloadDialog.tsx b/frontend/src/core/components/DownloadDialog.tsx new file mode 100644 index 0000000..d6353b6 --- /dev/null +++ b/frontend/src/core/components/DownloadDialog.tsx @@ -0,0 +1,113 @@ +import type { ReactNode } from "react"; +import { StyledDialog } from "./StyledDialog"; +import { MaterialButton, MaterialIcon } from "@/utils/material"; +import { OS_CONFIG, type DownloadOs } from "@/core/downloads/os"; +import styles from "./css/download-dialog.module.scss"; + +interface DownloadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + os: DownloadOs; +} + +function AndroidInstructions(): ReactNode { + return ( +
+

Установка на Android

+

+ Вы скачали APK-файл FromChat. Чтобы установить приложение: +

+ +
+ ); +} + +function IosInstructions(): ReactNode { + return ( +
+

Установка на iOS

+

+ Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone + или iPad, потребуется один из вариантов сторонней установки: +

+ +

+ К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет — именно поэтому я + бы сам iPhone не покупал 😄 +

+
+ ); +} + +function renderInstructions(os: DownloadOs): ReactNode { + if (os === "android") { + return ; + } + + if (os === "ios") { + return ; + } + + return null; +} + +export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) { + const osInfo = OS_CONFIG[os]; + + return ( + + onOpenChange(false)}> + Закрыть + + + } + > +
+
+
+ +
+
+

Thanks for downloading

+

+ FromChat для  + {osInfo.label} +

+
+
+ {renderInstructions(os)} +
+
+ ); +} + diff --git a/frontend/src/core/components/SplitButton.tsx b/frontend/src/core/components/SplitButton.tsx new file mode 100644 index 0000000..ed4755c --- /dev/null +++ b/frontend/src/core/components/SplitButton.tsx @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "motion/react"; +import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material"; +import useWindowSize from "@/core/hooks/useWindowSize"; +import styles from "./css/split-button.module.scss"; + +export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated"; + +interface SplitButtonProps { + text: ReactNode; + icon?: ReactNode | string; + menu: ReactNode; + menuOpen: boolean; + onMenuOpen: (open: boolean) => void; + onPrimaryClick?: () => void; + variant?: SplitButtonVariant; + disabled?: boolean; + className?: string; + menuAriaLabel?: string; +} + +export function SplitButton({ + text, + icon, + menu, + menuOpen: open, + onMenuOpen, + onPrimaryClick, + variant = "filled", + disabled = false, + className = "", + menuAriaLabel, +}: SplitButtonProps) { + const [isExiting, setIsExiting] = useState(false); + const rootRef = useRef(null); + const menuSegmentRef = useRef(null); + const menuRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ + top?: number; + bottom?: number; + left: number; + transform: string; + maxHeight: number; + } | null>(null); + const { width: windowWidth, height: windowHeight } = useWindowSize(); + const primaryRipple = useRippleHandlers(disabled); + const menuRipple = useRippleHandlers(disabled); + + const MENU_GAP = 16; + const EDGE_PAD = 16; + + const updateMenuPosition = useCallback(() => { + const anchor = menuSegmentRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + const menuEl = menuRef.current; + const menuWidth = menuEl?.offsetWidth ?? 220; + const menuHeight = menuEl?.offsetHeight ?? 320; + + const anchorX = rect.left + rect.width / 2; + let left: number; + let transform: string; + let top: number | undefined; + let bottom: number | undefined; + let maxHeight: number; + + const vw = window.innerWidth; + const vh = window.innerHeight; + + const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD; + const availableAbove = rect.top - MENU_GAP - EDGE_PAD; + const fitsBelow = menuHeight <= availableBelow; + const fitsAbove = menuHeight <= availableAbove; + const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow); + + if (placeAbove) { + bottom = vh - (rect.top - MENU_GAP); + maxHeight = Math.max(100, availableAbove); + } else { + top = rect.bottom + MENU_GAP; + maxHeight = Math.max(100, availableBelow); + } + + if (anchorX - menuWidth / 2 < EDGE_PAD) { + left = EDGE_PAD; + transform = "none"; + } else if (anchorX + menuWidth / 2 > vw - EDGE_PAD) { + left = vw - menuWidth - EDGE_PAD; + transform = "none"; + } else { + left = anchorX; + transform = "translateX(-50%)"; + } + + setMenuPosition({ top, bottom, left, transform, maxHeight }); + }, []); + + const closeMenu = useCallback(() => { + onMenuOpen(false); + setIsExiting(true); + }, [onMenuOpen]); + + useEffect(() => { + if (!open && !isExiting) { + setMenuPosition(null); + return; + } + if (!open) return; + + updateMenuPosition(); + window.addEventListener("scroll", updateMenuPosition, true); + + function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) { + const target = event.target as Node | null; + if (!target) return; + if (rootRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + closeMenu(); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") closeMenu(); + } + + document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener); + document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener); + document.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("scroll", updateMenuPosition, true); + document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener); + document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]); + + useEffect(() => { + if (!open) setIsExiting(true); + }, [open]); + + useLayoutEffect(() => { + if (open && menuRef.current) { + updateMenuPosition(); + } + }, [open, updateMenuPosition]); + + const handlePrimaryClick = () => { + if (disabled) { + return; + } + onPrimaryClick?.(); + }; + + const handleMenuToggle = () => { + if (disabled) return; + if (open) closeMenu(); + else onMenuOpen(true); + }; + + const variantClass = + variant === "tonal" + ? styles.variantTonal + : variant === "outlined" + ? styles.variantOutlined + : variant === "elevated" + ? styles.variantElevated + : styles.variantFilled; + + const renderIcon = () => { + if (!icon) { + return null; + } + + if (typeof icon === "string") { + return ; + } + + return {icon}; + }; + + const rootClasses = [ + styles.splitButton, + variantClass, + disabled ? styles.disabled : "", + className, + ] + .filter(Boolean) + .join(" "); + + return ( +
+ + + + + {(open || isExiting) && + menuPosition && + createPortal( + setIsExiting(false)}> + {open && ( + + {menu} + + )} + , + document.body + )} +
+ ); +} + diff --git a/frontend/src/core/components/css/download-dialog.module.scss b/frontend/src/core/components/css/download-dialog.module.scss new file mode 100644 index 0000000..f03da61 --- /dev/null +++ b/frontend/src/core/components/css/download-dialog.module.scss @@ -0,0 +1,114 @@ +@use "../../../css/material" as *; +@use "../../../css/colors" as *; +@use "sass:color"; + +.downloadDialog { + color: $color-dark-on-surface; +} + +.downloadDialogContent { + padding: 24px 24px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.iconWrapper { + width: 40px; + height: 40px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background-color: $color-dark-primary-container; + color: $color-dark-on-primary-container; +} + +.icon { + font-size: 22px; +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: 4px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.subtitle { + margin: 0; + font-size: 14px; + color: $color-dark-on-surface-variant; +} + +.osName { + font-weight: 600; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sectionTitle { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.text { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: $color-dark-on-surface-variant; +} + +.list { + margin: 0; + padding-left: 20px; + font-size: 14px; + line-height: 1.6; + color: $color-dark-on-surface-variant; + + li + li { + margin-top: 4px; + } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 24px 20px; + border-top: 1px solid rgba($color-dark-outline-variant, 0.4); + background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%); +} + +@media (max-width: 480px) { + .downloadDialogContent { + padding: 20px 16px 12px; + } + + .actions { + padding-inline: 16px; + } +} + diff --git a/frontend/src/core/components/css/split-button.module.scss b/frontend/src/core/components/css/split-button.module.scss new file mode 100644 index 0000000..b8cce5f --- /dev/null +++ b/frontend/src/core/components/css/split-button.module.scss @@ -0,0 +1,227 @@ +@use "../../../css/material" as *; + +$height: 40px; +$trailing-width: 48px; // 12 + 22 + 14 per spec +$between-space: 2px; +$outer-radius: calc(#{$height} / 2); // 20px +$inner-radius: 4px; +$inner-radius-hovered: 12px; + +.splitButton { + display: inline-flex; + align-items: stretch; + position: relative; + border-radius: $outer-radius; + font-family: inherit; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + background-color: transparent; + color: $color-dark-on-primary; + isolation: isolate; + + &.disabled { + opacity: 0.38; + pointer-events: none; + } + + .primarySegment, + .menuSegment { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + outline: none; + background-color: transparent; + color: inherit; + padding: 0; + min-height: $height; + cursor: pointer; + font: inherit; + box-sizing: border-box; + overflow: hidden; + + &:focus-visible { + outline: 2px solid $color-dark-primary; + outline-offset: 2px; + } + + mdui-ripple { + position: absolute; + inset: 0; + pointer-events: none; + } + } + + .primarySegment { + border-top-left-radius: $outer-radius; + border-bottom-left-radius: $outer-radius; + border-top-right-radius: $inner-radius; + border-bottom-right-radius: $inner-radius; + padding-inline: 16px 12px; + transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out; + + @media (hover: hover) { + &:hover { + border-top-right-radius: $inner-radius-hovered; + border-bottom-right-radius: $inner-radius-hovered; + } + } + + &:active { + border-top-right-radius: $inner-radius-hovered; + border-bottom-right-radius: $inner-radius-hovered; + } + + .primaryContent { + display: inline-flex; + align-items: center; + gap: 8px; + pointer-events: none; + user-select: none; + + .leadingIcon { + display: inline-flex; + align-items: center; + justify-content: center; + + .leadingIconIcon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + width: 20px; + height: 20px; + } + } + + .label { + white-space: nowrap; + } + } + } + + .menuSegment { + width: $trailing-width; + border-top-right-radius: $outer-radius; + border-bottom-right-radius: $outer-radius; + border-top-left-radius: $inner-radius; + border-bottom-left-radius: $inner-radius; + margin-left: $between-space; + padding-inline: 12px 14px; + transition: + border-top-left-radius 0.18s ease-out, + border-bottom-left-radius 0.18s ease-out, + padding-inline 0.18s ease-out; + + @media (hover: hover) { + &:hover { + border-top-left-radius: $inner-radius-hovered; + border-bottom-left-radius: $inner-radius-hovered; + } + } + + &:active { + border-top-left-radius: $inner-radius-hovered; + border-bottom-left-radius: $inner-radius-hovered; + } + + .menuIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + font-size: 22px; + transition: transform 0.18s ease-out; + pointer-events: none; + user-select: none; + + mdui-icon { + width: inherit; + height: inherit; + font-size: inherit; + } + } + } + + &[data-open="true"] .menuSegment { + $size: calc($trailing-width / 2); + border-radius: $size; + padding-inline: 13px 13px; + + .menuIcon { + transform: rotate(-180deg); + } + } + + &.variantFilled { + .primarySegment, .menuSegment { + background-color: $color-dark-primary; + color: $color-dark-on-primary; + border: none; + } + } + + &.variantTonal { + .primarySegment, .menuSegment { + background-color: $color-dark-primary-container; + color: $color-dark-on-primary-container; + border: none; + } + } + + &.variantOutlined { + .primarySegment, .menuSegment { + border: 1px solid rgba($color-dark-outline, 0.8); + border: none; + background-color: transparent; + color: $color-dark-on-surface; + } + } + + &.variantElevated { + .primarySegment, .menuSegment { + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.3), + 0 1px 2px rgba(0, 0, 0, 0.15); + background-color: $color-dark-surface-container-low; + color: $color-dark-on-surface; + } + } + +} + +$menu-padding: 8px; + +.menu { + padding: $menu-padding; + min-width: 220px; + border-radius: 16px; + background-color: rgba($color-dark-surface-container-high, 0.4); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + z-index: 100000000; + + // Custom slim semi-transparent scrollbar + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba($color-dark-on-surface, 0.25); + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba($color-dark-on-surface, 0.4); + } + + scrollbar-width: thin; + scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent; +} \ No newline at end of file diff --git a/frontend/src/images/linux.svg b/frontend/src/images/linux.svg new file mode 100644 index 0000000..4915de3 --- /dev/null +++ b/frontend/src/images/linux.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/images/mac.svg b/frontend/src/images/mac.svg new file mode 100644 index 0000000..3745ebe --- /dev/null +++ b/frontend/src/images/mac.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/images/windows.svg b/frontend/src/images/windows.svg new file mode 100644 index 0000000..96f79c1 --- /dev/null +++ b/frontend/src/images/windows.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/pages/home/HomeFooter.tsx b/frontend/src/pages/home/HomeFooter.tsx index fc6ea9d..d4a4e7f 100644 --- a/frontend/src/pages/home/HomeFooter.tsx +++ b/frontend/src/pages/home/HomeFooter.tsx @@ -15,7 +15,7 @@ export function HomeFooter() {
- + Скачать приложение diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index ca3cdc7..c630a83 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,13 +1,18 @@ import { useNavigate } from "react-router-dom"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import styles from "./home.module.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; -import { MaterialButton, MaterialIcon } from "@/utils/material"; +import { MaterialButton, MaterialIcon, MaterialList, MaterialListItem } from "@/utils/material"; import generalChatScreenshot from "../../images/screenshots/general-chat.png"; import dmScreenshot from "../../images/screenshots/dm.png"; +import windowsIcon from "../../images/windows.svg"; +import linuxIcon from "../../images/linux.svg"; +import macIcon from "../../images/mac.svg"; import { HomeHeader } from "./HomeHeader"; import { HomeFooter } from "./HomeFooter"; -import { OS_CONFIG, ALL_OS } from "@/core/downloads/os"; +import { SplitButton } from "@/core/components/SplitButton"; +import { DownloadDialog } from "@/core/components/DownloadDialog"; +import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/core/downloads/os"; interface FeatureSectionProps { title: ReactNode; @@ -47,6 +52,28 @@ export default function HomePage() { const navigate = useNavigate(); const { isMobile } = useDownloadAppScreen(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogOs, setDialogOs] = useState(() => detectOs()); + const [menuOpen, setMenuOpen] = useState(false); + + const triggerDownload = (os: DownloadOs): boolean => { + if (typeof document === "undefined") { + return false; + } + + setDialogOs(os); + setDialogOpen(true); + + const link = document.createElement("a"); + link.href = `/api/download/${os}`; + link.download = ""; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return true; + }; + return (
@@ -70,13 +97,39 @@ export default function HomePage() { Открыть веб-версию )} - navigate("/download-app")} + - Скачать приложение - + onPrimaryClick={() => triggerDownload(detectOs())} + menuOpen={menuOpen} + onMenuOpen={setMenuOpen} + menu={( + + {ALL_OS.map((os) => ( + { + if (triggerDownload(os)) setMenuOpen(false); + }} + > + {["windows", "linux", "macos"].includes(os) && ( + + )} + + ))} + + )} + />
@@ -95,7 +148,7 @@ export default function HomePage() { -
+

Скачайте приложение

@@ -122,17 +175,13 @@ export default function HomePage() { {OS_CONFIG[os].description}
))} @@ -174,6 +223,7 @@ export default function HomePage() {
+
); diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss index 1a64347..f904f1a 100644 --- a/frontend/src/pages/home/home.module.scss +++ b/frontend/src/pages/home/home.module.scss @@ -339,6 +339,21 @@ $radius-pill: 9999px; } } +.menuCustomIcon { + display: inline-block; + width: 24px; + height: 24px; + background-color: $color-dark-on-surface-variant; + mask-image: var(--menu-custom-icon-url); + -webkit-mask-image: var(--menu-custom-icon-url); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + // Keyframes for animations @keyframes neonGlow { 0% { diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx index e8785ff..ecd4592 100644 --- a/frontend/src/utils/material.tsx +++ b/frontend/src/utils/material.tsx @@ -23,8 +23,9 @@ import 'mdui/components/badge'; import "mdui/mdui.css"; import 'mdui/components/circular-progress'; +import { useCallback, useRef } from "react"; import { setColorScheme } from 'mdui/functions/setColorScheme'; -import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react'; +import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react'; import type { TextField } from 'mdui/components/text-field'; import type { Switch } from 'mdui/components/switch'; import type { Override } from '@/core/types'; @@ -145,8 +146,67 @@ export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) { return } /> } -export type MaterialRippleProps = BasePropCustomization<"div", MDUIRipple>; +export type MaterialRippleProps = NoChildren>; export function MaterialRipple(props: MaterialRippleProps) { - // Wrapper component for future custom ripple usage; MDUI buttons already include real ripple. - return
} />; + return } /> +} + +/** + * Returns pointer handlers that forward press and hover events to an mdui-ripple element. + * Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button). + * + * @example + * const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled); + * + */ +export function useRippleHandlers( + disabled = false +): { + rippleRef: RefObject; + onPointerDown: (e: React.PointerEvent) => void; + onPointerEnter: (e: React.PointerEvent) => void; + onPointerLeave: (e: React.PointerEvent) => void; +} { + const rippleRef = useRef(null); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (disabled || e.button !== 0) return; + const ripple = rippleRef.current; + if (!ripple?.startPress) return; + ripple.startPress(e.nativeEvent); + const btn = e.currentTarget as HTMLElement; + const endPress = () => { + ripple.endPress?.(); + btn.removeEventListener("pointerup", endPress); + btn.removeEventListener("pointercancel", endPress); + btn.removeEventListener("pointerleave", endPress); + }; + btn.addEventListener("pointerup", endPress); + btn.addEventListener("pointercancel", endPress); + btn.addEventListener("pointerleave", endPress); + }, + [disabled] + ); + + const onPointerEnter = useCallback( + (e: React.PointerEvent) => { + if (disabled || e.pointerType !== "mouse") return; + rippleRef.current?.startHover?.(); + }, + [disabled] + ); + + const onPointerLeave = useCallback( + (e: React.PointerEvent) => { + if (disabled || e.pointerType !== "mouse") return; + rippleRef.current?.endHover?.(); + }, + [disabled] + ); + + return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave }; } \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 941c178..eca7273 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -8,6 +8,12 @@ declare global { interface SyntheticEvent { target: EventTarget & T; } + + namespace JSX { + interface IntrinsicElements { + "mdui-ripple": DetailedHTMLProps, HTMLElement>; + } + } } // Augment DOM event listeners to provide typed target for ALL elements