@jotis-me/react 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/dist/components/ActivityFeed.d.ts +29 -0
- package/dist/components/ActivityFeed.js +10 -0
- package/dist/components/Alert.d.ts +34 -0
- package/dist/components/Alert.js +27 -0
- package/dist/components/AppIcon.d.ts +27 -0
- package/dist/components/AppIcon.js +9 -0
- package/dist/components/Avatar.d.ts +20 -0
- package/dist/components/Avatar.js +27 -0
- package/dist/components/AvatarGroup.d.ts +10 -0
- package/dist/components/AvatarGroup.js +7 -0
- package/dist/components/AvatarLabel.d.ts +10 -0
- package/dist/components/AvatarLabel.js +7 -0
- package/dist/components/BackgroundPattern.d.ts +35 -0
- package/dist/components/BackgroundPattern.js +73 -0
- package/dist/components/Badge.d.ts +38 -0
- package/dist/components/Badge.js +82 -0
- package/dist/components/Banner.d.ts +48 -0
- package/dist/components/Banner.js +60 -0
- package/dist/components/BarChart.d.ts +25 -0
- package/dist/components/BarChart.js +30 -0
- package/dist/components/Breadcrumbs.d.ts +19 -0
- package/dist/components/Breadcrumbs.js +14 -0
- package/dist/components/BrowserFrame.d.ts +18 -0
- package/dist/components/BrowserFrame.js +13 -0
- package/dist/components/Button.d.ts +51 -0
- package/dist/components/Button.js +54 -0
- package/dist/components/ButtonGroup.d.ts +21 -0
- package/dist/components/ButtonGroup.js +14 -0
- package/dist/components/CalendarView.d.ts +30 -0
- package/dist/components/CalendarView.js +43 -0
- package/dist/components/CamFrame.d.ts +21 -0
- package/dist/components/CamFrame.js +11 -0
- package/dist/components/CardHeader.d.ts +32 -0
- package/dist/components/CardHeader.js +12 -0
- package/dist/components/ChannelBanner.d.ts +24 -0
- package/dist/components/ChannelBanner.js +13 -0
- package/dist/components/ChannelPanel.d.ts +36 -0
- package/dist/components/ChannelPanel.js +12 -0
- package/dist/components/ChartLegend.d.ts +12 -0
- package/dist/components/ChartLegend.js +7 -0
- package/dist/components/ChatBadge.d.ts +15 -0
- package/dist/components/ChatBadge.js +39 -0
- package/dist/components/ChatBox.d.ts +42 -0
- package/dist/components/ChatBox.js +35 -0
- package/dist/components/Checkbox.d.ts +17 -0
- package/dist/components/Checkbox.js +18 -0
- package/dist/components/CloseButton.d.ts +7 -0
- package/dist/components/CloseButton.js +8 -0
- package/dist/components/CodeSnippet.d.ts +23 -0
- package/dist/components/CodeSnippet.js +75 -0
- package/dist/components/ColorPicker.d.ts +15 -0
- package/dist/components/ColorPicker.js +42 -0
- package/dist/components/CommandMenu.d.ts +72 -0
- package/dist/components/CommandMenu.js +102 -0
- package/dist/components/ContentDivider.d.ts +17 -0
- package/dist/components/ContentDivider.js +13 -0
- package/dist/components/CreditCard.d.ts +28 -0
- package/dist/components/CreditCard.js +63 -0
- package/dist/components/DatePicker.d.ts +48 -0
- package/dist/components/DatePicker.js +105 -0
- package/dist/components/Dropdown.d.ts +39 -0
- package/dist/components/Dropdown.js +39 -0
- package/dist/components/EmptyState.d.ts +18 -0
- package/dist/components/EmptyState.js +7 -0
- package/dist/components/FeaturedIcon.d.ts +17 -0
- package/dist/components/FeaturedIcon.js +32 -0
- package/dist/components/FileUpload.d.ts +39 -0
- package/dist/components/FileUpload.js +50 -0
- package/dist/components/FilterBar.d.ts +46 -0
- package/dist/components/FilterBar.js +24 -0
- package/dist/components/Icon.d.ts +5 -0
- package/dist/components/Icon.js +9 -0
- package/dist/components/InlineCTA.d.ts +19 -0
- package/dist/components/InlineCTA.js +7 -0
- package/dist/components/Input.d.ts +16 -0
- package/dist/components/Input.js +10 -0
- package/dist/components/LineChart.d.ts +25 -0
- package/dist/components/LineChart.js +23 -0
- package/dist/components/Message.d.ts +22 -0
- package/dist/components/Message.js +10 -0
- package/dist/components/MessageInput.d.ts +17 -0
- package/dist/components/MessageInput.js +25 -0
- package/dist/components/MetricCard.d.ts +28 -0
- package/dist/components/MetricCard.js +11 -0
- package/dist/components/Modal.d.ts +45 -0
- package/dist/components/Modal.js +15 -0
- package/dist/components/Notification.d.ts +15 -0
- package/dist/components/Notification.js +33 -0
- package/dist/components/NowPlaying.d.ts +25 -0
- package/dist/components/NowPlaying.js +16 -0
- package/dist/components/PageHeader.d.ts +22 -0
- package/dist/components/PageHeader.js +8 -0
- package/dist/components/Pagination.d.ts +29 -0
- package/dist/components/Pagination.js +33 -0
- package/dist/components/PieChart.d.ts +32 -0
- package/dist/components/PieChart.js +30 -0
- package/dist/components/ProgressBar.d.ts +11 -0
- package/dist/components/ProgressBar.js +8 -0
- package/dist/components/Radio.d.ts +15 -0
- package/dist/components/Radio.js +10 -0
- package/dist/components/RadioGroup.d.ts +26 -0
- package/dist/components/RadioGroup.js +9 -0
- package/dist/components/SectionFooter.d.ts +12 -0
- package/dist/components/SectionFooter.js +7 -0
- package/dist/components/SectionHeader.d.ts +24 -0
- package/dist/components/SectionHeader.js +7 -0
- package/dist/components/Select.d.ts +22 -0
- package/dist/components/Select.js +14 -0
- package/dist/components/SidebarNav.d.ts +38 -0
- package/dist/components/SidebarNav.js +26 -0
- package/dist/components/Skeleton.d.ts +15 -0
- package/dist/components/Skeleton.js +10 -0
- package/dist/components/Slideout.d.ts +28 -0
- package/dist/components/Slideout.js +15 -0
- package/dist/components/Slider.d.ts +15 -0
- package/dist/components/Slider.js +17 -0
- package/dist/components/Spinner.d.ts +11 -0
- package/dist/components/Spinner.js +12 -0
- package/dist/components/StreamAlert.d.ts +34 -0
- package/dist/components/StreamAlert.js +10 -0
- package/dist/components/StreamCountdown.d.ts +30 -0
- package/dist/components/StreamCountdown.js +44 -0
- package/dist/components/StreamEvents.d.ts +40 -0
- package/dist/components/StreamEvents.js +20 -0
- package/dist/components/StreamGoal.d.ts +22 -0
- package/dist/components/StreamGoal.js +21 -0
- package/dist/components/StreamScene.d.ts +35 -0
- package/dist/components/StreamScene.js +33 -0
- package/dist/components/StreamStatus.d.ts +28 -0
- package/dist/components/StreamStatus.js +26 -0
- package/dist/components/Table.d.ts +36 -0
- package/dist/components/Table.js +15 -0
- package/dist/components/Tabs.d.ts +38 -0
- package/dist/components/Tabs.js +34 -0
- package/dist/components/Tag.d.ts +18 -0
- package/dist/components/Tag.js +15 -0
- package/dist/components/TextEditor.d.ts +37 -0
- package/dist/components/TextEditor.js +103 -0
- package/dist/components/Textarea.d.ts +6 -0
- package/dist/components/Textarea.js +10 -0
- package/dist/components/Toggle.d.ts +22 -0
- package/dist/components/Toggle.js +12 -0
- package/dist/components/Tooltip.d.ts +15 -0
- package/dist/components/Tooltip.js +27 -0
- package/dist/components/TreeView.d.ts +33 -0
- package/dist/components/TreeView.js +48 -0
- package/dist/components/VideoPlayer.d.ts +25 -0
- package/dist/components/VideoPlayer.js +52 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +76 -0
- package/dist/internal/chart.d.ts +38 -0
- package/dist/internal/chart.js +33 -0
- package/dist/internal/choice.d.ts +18 -0
- package/dist/internal/choice.js +17 -0
- package/dist/internal/cx.d.ts +3 -0
- package/dist/internal/cx.js +26 -0
- package/dist/internal/field.d.ts +29 -0
- package/dist/internal/field.js +23 -0
- package/dist/internal/format.d.ts +12 -0
- package/dist/internal/format.js +40 -0
- package/dist/internal/kbd.d.ts +6 -0
- package/dist/internal/kbd.js +6 -0
- package/dist/internal/layer.d.ts +21 -0
- package/dist/internal/layer.js +27 -0
- package/dist/internal/line.d.ts +2 -0
- package/dist/internal/line.js +5 -0
- package/dist/internal/overlay.d.ts +2 -0
- package/dist/internal/overlay.js +4 -0
- package/dist/internal/stream-stage.d.ts +18 -0
- package/dist/internal/stream-stage.js +38 -0
- package/dist/internal/stream.d.ts +56 -0
- package/dist/internal/stream.js +112 -0
- package/dist/internal/strings.d.ts +100 -0
- package/dist/internal/strings.js +102 -0
- package/dist/internal/use-controllable.d.ts +5 -0
- package/dist/internal/use-controllable.js +17 -0
- package/package.json +66 -5
- package/styles.css +7 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
import { Line } from "../internal/line.js";
|
|
4
|
+
/** Pie de sección o de tarjeta con línea superior: un enlace o grupo a la izquierda y las acciones a la derecha. */
|
|
5
|
+
export function SectionFooter({ start, actions, card, className }) {
|
|
6
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-4 font-body", className), children: [_jsx(Line, {}), _jsxs("div", { className: cx("flex flex-wrap items-center gap-4", card && "px-6 pb-4"), children: [start ?? null, _jsx("div", { className: "ml-auto flex flex-wrap items-center gap-3", children: actions ?? null })] })] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface SectionHeaderProps {
|
|
3
|
+
/** Título de la sección (`<h2>`): `text-lg` semibold, `text-md` en `sm`. */
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
/** Una frase en `text-tertiary` bajo el título. */
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
/** Botones a la derecha: la acción principal, la última. */
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* Tamaño del título.
|
|
11
|
+
* @default "md"
|
|
12
|
+
*/
|
|
13
|
+
size?: "sm" | "md";
|
|
14
|
+
/**
|
|
15
|
+
* Línea inferior en `border-secondary`.
|
|
16
|
+
* @default true
|
|
17
|
+
*/
|
|
18
|
+
divider?: boolean;
|
|
19
|
+
/** Lo que va bajo el título, como unas `Tabs` o una búsqueda. */
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
/** Cabecera de una sección dentro de una página: título, descripción, acciones y línea. */
|
|
24
|
+
export declare function SectionHeader({ title, description, actions, size, divider, children, className, }: SectionHeaderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
import { Line } from "../internal/line.js";
|
|
4
|
+
/** Cabecera de una sección dentro de una página: título, descripción, acciones y línea. */
|
|
5
|
+
export function SectionHeader({ title, description, actions, size = "md", divider = true, children, className, }) {
|
|
6
|
+
return (_jsxs("div", { className: cx("flex flex-col gap-5 font-body", className), children: [_jsxs("div", { className: "flex flex-wrap items-start gap-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { className: cx("m-0 font-semibold text-primary", size === "sm" ? "text-md" : "text-lg"), children: title }), description ? _jsx("p", { className: "mt-0.5 mb-0 text-sm text-tertiary", children: description }) : null] }), actions ? _jsx("div", { className: "flex flex-wrap items-center gap-3", children: actions }) : null] }), children ?? null, divider ? _jsx(Line, {}) : null] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ReactNode, type SelectHTMLAttributes } from "react";
|
|
2
|
+
import { type FieldProps, type FieldSize } from "../internal/field.js";
|
|
3
|
+
import { type IconName } from "./Icon.js";
|
|
4
|
+
export type SelectOption = string | {
|
|
5
|
+
value: string;
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
export interface SelectProps extends FieldProps, Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
|
9
|
+
/** Lista corta (hasta ~12). Con más o con búsqueda, usa otro componente. */
|
|
10
|
+
options: SelectOption[];
|
|
11
|
+
/** Primera opción, no seleccionable, mientras no haya valor. */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Alto: `sm` 36 · `md` 40 · `lg` 44.
|
|
15
|
+
* @default "md"
|
|
16
|
+
*/
|
|
17
|
+
size?: FieldSize;
|
|
18
|
+
/** Icono inicial, 20px en `fg-quaternary`. */
|
|
19
|
+
icon?: IconName;
|
|
20
|
+
}
|
|
21
|
+
/** Desplegable nativo con el aspecto de `Input` y un chevron, para una lista corta. */
|
|
22
|
+
export declare function Select({ label, required, hint, error, options, placeholder, size, icon, id: givenId, className, disabled, ...rest }: SelectProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { cx } from "../internal/cx.js";
|
|
4
|
+
import { controlClasses, describedBy, FieldShell, nativeClasses, } from "../internal/field.js";
|
|
5
|
+
import { Icon } from "./Icon.js";
|
|
6
|
+
/** Desplegable nativo con el aspecto de `Input` y un chevron, para una lista corta. */
|
|
7
|
+
export function Select({ label, required, hint, error, options, placeholder, size = "md", icon, id: givenId, className, disabled, ...rest }) {
|
|
8
|
+
const autoId = useId();
|
|
9
|
+
const id = givenId ?? autoId;
|
|
10
|
+
return (_jsx(FieldShell, { id: id, label: label, required: required, hint: hint, error: error, size: size, className: className, children: _jsxs("div", { className: controlClasses(size, error, disabled), children: [icon ? _jsx(Icon, { name: icon, size: 20 }) : null, _jsxs("select", { id: id, required: required, disabled: disabled, defaultValue: rest.value === undefined && placeholder ? "" : undefined, ...describedBy(id, { hint, error }), ...rest, className: cx(nativeClasses(size), "cursor-pointer appearance-none"), children: [placeholder ? (_jsx("option", { value: "", disabled: true, children: placeholder })) : null, options.map((o) => {
|
|
11
|
+
const { value, label } = typeof o === "string" ? { value: o, label: o } : o;
|
|
12
|
+
return (_jsx("option", { value: value, children: label }, value));
|
|
13
|
+
})] }), _jsx("span", { className: "pointer-events-none inline-flex", children: _jsx(Icon, { name: "chevron-down", size: 20 }) })] }) }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type AvatarLabelProps } from "./AvatarLabel.js";
|
|
3
|
+
import { type IconName } from "./Icon.js";
|
|
4
|
+
export interface SidebarNavItem {
|
|
5
|
+
icon?: IconName;
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
href?: string;
|
|
8
|
+
/** Página actual: fondo `bg-secondary_hover` y `aria-current="page"`. */
|
|
9
|
+
active?: boolean;
|
|
10
|
+
/** Contador o aviso a la derecha, en un `Badge` gris. */
|
|
11
|
+
badge?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export interface SidebarNavLabels {
|
|
14
|
+
/** Nombre de la navegación. @default "Principal" */
|
|
15
|
+
nav: string;
|
|
16
|
+
/** Placeholder y nombre del buscador. @default "Buscar" */
|
|
17
|
+
search: string;
|
|
18
|
+
/** Nombre del botón de salir. @default "Cerrar sesión" */
|
|
19
|
+
logout: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SidebarNavProps {
|
|
22
|
+
/** Marca arriba: texto (en Bricolage Grotesque bold) o un nodo, como un `AppIcon` con el nombre. */
|
|
23
|
+
brand?: ReactNode;
|
|
24
|
+
/** Muestra el buscador bajo la marca. */
|
|
25
|
+
search?: boolean;
|
|
26
|
+
items: SidebarNavItem[];
|
|
27
|
+
/** Ítems pegados abajo, como Ajustes. */
|
|
28
|
+
footerItems?: Omit<SidebarNavItem, "badge">[];
|
|
29
|
+
/** Ficha del usuario al pie, con el botón de cerrar sesión. */
|
|
30
|
+
user?: AvatarLabelProps;
|
|
31
|
+
/** Al pulsar el botón de cerrar sesión de la ficha del usuario (ampliación sobre la guía). */
|
|
32
|
+
onLogout?: () => void;
|
|
33
|
+
/** Textos que trae el componente, para sustituirlos. */
|
|
34
|
+
labels?: Partial<SidebarNavLabels>;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Navegación lateral de 296px de las apps de la suite: marca, búsqueda, secciones, ajustes y ficha del usuario. */
|
|
38
|
+
export declare function SidebarNav({ brand, search, items, footerItems, user, onLogout, labels, className, }: SidebarNavProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
import { defaultStrings } from "../internal/strings.js";
|
|
4
|
+
import { AvatarLabel } from "./AvatarLabel.js";
|
|
5
|
+
import { Badge } from "./Badge.js";
|
|
6
|
+
import { Button } from "./Button.js";
|
|
7
|
+
import { Icon } from "./Icon.js";
|
|
8
|
+
import { Input } from "./Input.js";
|
|
9
|
+
const ITEM = "flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-md font-semibold text-secondary no-underline [&_svg]:text-fg-quaternary";
|
|
10
|
+
const LINK = "cursor-pointer transition-colors duration-100 hover:bg-primary_hover hover:text-secondary_hover focus-visible:shadow-focus-ring focus-visible:outline-none aria-[current=page]:bg-secondary_hover aria-[current=page]:text-secondary_hover aria-[current=page]:[&_svg]:text-fg-tertiary";
|
|
11
|
+
function NavList({ items }) {
|
|
12
|
+
return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-0.5 p-0", children: items.map((item, i) => {
|
|
13
|
+
const content = (_jsxs(_Fragment, { children: [item.icon ? _jsx(Icon, { name: item.icon, size: 24 }) : null, item.label, item.badge != null ? (_jsx(Badge, { size: "sm", color: "gray", className: "ml-auto", children: item.badge })) : null] }));
|
|
14
|
+
return (_jsx("li", { children: item.href ? (_jsx("a", { href: item.href, "aria-current": item.active ? "page" : undefined, className: cx(ITEM, LINK), children: content })) : (_jsx("span", { "aria-current": item.active ? "page" : undefined, className: cx(ITEM, "aria-[current=page]:bg-secondary_hover aria-[current=page]:text-secondary_hover aria-[current=page]:[&_svg]:text-fg-tertiary"), children: content })) }, i));
|
|
15
|
+
}) }));
|
|
16
|
+
}
|
|
17
|
+
/** Navegación lateral de 296px de las apps de la suite: marca, búsqueda, secciones, ajustes y ficha del usuario. */
|
|
18
|
+
export function SidebarNav({ brand, search, items, footerItems, user, onLogout, labels, className, }) {
|
|
19
|
+
const t = {
|
|
20
|
+
nav: defaultStrings.mainNav,
|
|
21
|
+
search: defaultStrings.search,
|
|
22
|
+
logout: defaultStrings.logout,
|
|
23
|
+
...labels,
|
|
24
|
+
};
|
|
25
|
+
return (_jsxs("nav", { "aria-label": t.nav, className: cx("flex h-full w-[296px] flex-col gap-6 bg-primary px-4 py-6 font-body shadow-[inset_-1px_0_0_var(--border-secondary)]", className), children: [brand ? (_jsx("div", { className: "flex items-center gap-2 px-2 font-display text-lg font-bold text-primary", children: brand })) : null, search ? (_jsx(Input, { size: "md", icon: "search-lg", placeholder: t.search, "aria-label": t.search })) : null, _jsx(NavList, { items: items }), _jsxs("div", { className: "mt-auto flex flex-col gap-6", children: [footerItems ? _jsx(NavList, { items: footerItems }) : null, user ? (_jsxs("div", { className: "flex items-center justify-between gap-2 px-2 pt-6 shadow-[inset_0_1px_0_var(--border-secondary)]", children: [_jsx(AvatarLabel, { size: "md", status: "online", ...user }), _jsx(Button, { hierarchy: "tertiary", size: "sm", iconOnly: "log-out-01", label: t.logout, onClick: onLogout })] })) : null] })] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SkeletonProps {
|
|
2
|
+
/** Por defecto, `100%`. */
|
|
3
|
+
width?: number | string;
|
|
4
|
+
/** Por defecto, 16px. */
|
|
5
|
+
height?: number | string;
|
|
6
|
+
/** Radio; por defecto, `radius-md`. */
|
|
7
|
+
radius?: number | string;
|
|
8
|
+
/** Círculo, para avatares. */
|
|
9
|
+
circle?: boolean;
|
|
10
|
+
/** N líneas de texto de 12px; la última al 60 %. */
|
|
11
|
+
lines?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Bloque gris que reserva el sitio del contenido mientras carga. Imita la forma real. */
|
|
15
|
+
export declare function Skeleton({ width, height, radius, circle, lines, className }: SkeletonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
// Pulso suave de la guía (1,4 s); sin animación si el sistema pide menos movimiento
|
|
4
|
+
const BLOCK = "block rounded-md bg-tertiary motion-safe:animate-[pulse_1.4s_ease-in-out_infinite] motion-reduce:animate-none";
|
|
5
|
+
/** Bloque gris que reserva el sitio del contenido mientras carga. Imita la forma real. */
|
|
6
|
+
export function Skeleton({ width, height, radius, circle, lines, className }) {
|
|
7
|
+
if (lines)
|
|
8
|
+
return (_jsx("div", { "aria-hidden": "true", className: cx("flex flex-col gap-2", className), children: Array.from({ length: lines }, (_, i) => (_jsx("span", { className: BLOCK, style: { height: 12, width: i === lines - 1 ? "60%" : "100%" } }, i))) }));
|
|
9
|
+
return (_jsx("span", { "aria-hidden": "true", className: cx(BLOCK, circle && "rounded-full", className), style: { width: width ?? "100%", height: height ?? 16, borderRadius: radius } }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface SlideoutProps {
|
|
3
|
+
/**
|
|
4
|
+
* Visible. Con `false` no se pinta.
|
|
5
|
+
* @default true
|
|
6
|
+
*/
|
|
7
|
+
open?: boolean;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
description?: ReactNode;
|
|
10
|
+
/** Contenido: detalle o formulario. Se desplaza si no cabe. */
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
/** Botones del pie, alineados a la derecha. */
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
/** Cierra con la X, Escape o un clic fuera. Sin él no hay X y el panel no se puede descartar. */
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Ancho máximo del panel.
|
|
18
|
+
* @default 400
|
|
19
|
+
*/
|
|
20
|
+
width?: number | string;
|
|
21
|
+
/** Se pinta dentro de su contenedor (posición relativa) en vez de sobre toda la página, sin atrapar el foco. */
|
|
22
|
+
contained?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Panel lateral derecho para ver o editar el detalle de un elemento sin perder la lista de fondo.
|
|
26
|
+
* Más de tres campos, o comparar con la lista: slideout. Para confirmar una decisión, `Modal`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function Slideout({ open, title, description, children, actions, onClose, width, contained, }: SlideoutProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId } from "react";
|
|
4
|
+
import { Layer } from "../internal/layer.js";
|
|
5
|
+
import { CloseButton } from "./CloseButton.js";
|
|
6
|
+
/**
|
|
7
|
+
* Panel lateral derecho para ver o editar el detalle de un elemento sin perder la lista de fondo.
|
|
8
|
+
* Más de tres campos, o comparar con la lista: slideout. Para confirmar una decisión, `Modal`.
|
|
9
|
+
*/
|
|
10
|
+
export function Slideout({ open = true, title, description, children, actions, onClose, width, contained, }) {
|
|
11
|
+
const id = useId();
|
|
12
|
+
if (!open)
|
|
13
|
+
return null;
|
|
14
|
+
return (_jsxs(Layer, { contained: contained, onClose: onClose, overlayClassName: "items-stretch justify-end p-0", panelClassName: "flex h-full w-full max-w-[400px] flex-col bg-primary_alt font-body shadow-[var(--shadow-xl),inset_1px_0_0_var(--border-secondary_alt)]", panelStyle: width !== undefined ? { maxWidth: width } : undefined, labelledBy: `${id}-title`, describedBy: description ? `${id}-desc` : undefined, children: [_jsxs("div", { className: "flex gap-2 p-6", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h2", { id: `${id}-title`, className: "m-0 text-lg font-semibold text-primary", children: title }), description ? (_jsx("p", { id: `${id}-desc`, className: "mt-1 mb-0 text-sm text-tertiary", children: description })) : null] }), onClose ? _jsx(CloseButton, { onClick: onClose, className: "-mt-2 -mr-2" }) : null] }), _jsx("div", { className: "flex flex-1 flex-col gap-6 overflow-auto px-6 pb-6", children: children }), actions ? (_jsx("div", { className: "flex justify-end gap-3 px-6 py-4 shadow-[inset_0_1px_0_var(--border-secondary)]", children: actions })) : null] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SliderProps {
|
|
2
|
+
/** @default 0 */
|
|
3
|
+
min?: number;
|
|
4
|
+
/** @default 100 */
|
|
5
|
+
max?: number;
|
|
6
|
+
value?: number;
|
|
7
|
+
/** @default 50 */
|
|
8
|
+
defaultValue?: number;
|
|
9
|
+
onChange?: (value: number) => void;
|
|
10
|
+
/** Qué se ajusta: "Presupuesto", "Volumen". */
|
|
11
|
+
"aria-label"?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Control deslizante para un valor dentro de un rango; flechas, Inicio y Fin con teclado. */
|
|
15
|
+
export declare function Slider({ min, max, value, defaultValue, onChange, "aria-label": ariaLabel, className, }: SliderProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Slider as AriaSlider, SliderThumb, SliderTrack } from "react-aria-components";
|
|
4
|
+
import { cx } from "../internal/cx.js";
|
|
5
|
+
/** Control deslizante para un valor dentro de un rango; flechas, Inicio y Fin con teclado. */
|
|
6
|
+
export function Slider({ min = 0, max = 100, value, defaultValue = 50, onChange, "aria-label": ariaLabel, className, }) {
|
|
7
|
+
return (_jsx(AriaSlider, { minValue: min, maxValue: max, value: value, defaultValue: value === undefined ? defaultValue : undefined, onChange: (v) => onChange?.(v), "aria-label": ariaLabel,
|
|
8
|
+
// inline-block como el range nativo: el borde inferior se apoya en la línea base
|
|
9
|
+
className: cx("inline-block w-full", className), children: _jsx(SliderTrack, { className: "relative h-2 w-full cursor-pointer rounded-full bg-quaternary", children: ({ state }) => {
|
|
10
|
+
const p = state.getThumbPercent(0);
|
|
11
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute inset-y-0 left-0 rounded-l-full bg-fg-brand-primary", style: { width: `${p * 100}%` } }), _jsx(SliderThumb, { className: "top-1/2 size-6 rounded-full border-2 border-slider-handle-border bg-slider-handle-bg shadow-md outline-none data-focus-visible:shadow-focus-ring", style: ({ defaultStyle }) => ({
|
|
12
|
+
...defaultStyle,
|
|
13
|
+
left: `calc((100% - 24px) * ${p})`,
|
|
14
|
+
transform: "translateY(-50%)",
|
|
15
|
+
}) })] }));
|
|
16
|
+
} }) }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type SpinnerSize = "sm" | "md" | "lg" | "xl";
|
|
3
|
+
export interface SpinnerProps {
|
|
4
|
+
/** Diámetro: `sm` 32 · `md` 48 · `lg` 56 · `xl` 64. */
|
|
5
|
+
size?: SpinnerSize;
|
|
6
|
+
/** Texto bajo el círculo. Por defecto, "Cargando…"; `false` lo deja solo para lectores de pantalla. */
|
|
7
|
+
label?: ReactNode | false;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Indicador de carga circular para esperas de más de un segundo sin forma conocida. */
|
|
11
|
+
export declare function Spinner({ size, label, className }: SpinnerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
import { defaultStrings } from "../internal/strings.js";
|
|
4
|
+
const PX = { sm: 32, md: 48, lg: 56, xl: 64 };
|
|
5
|
+
/** Indicador de carga circular para esperas de más de un segundo sin forma conocida. */
|
|
6
|
+
export function Spinner({ size = "md", label, className }) {
|
|
7
|
+
const px = PX[size];
|
|
8
|
+
const stroke = px <= 32 ? 4 : 6;
|
|
9
|
+
const r = (px - stroke) / 2;
|
|
10
|
+
const circumference = 2 * Math.PI * r;
|
|
11
|
+
return (_jsxs("div", { role: "status", "aria-live": "polite", className: cx("inline-flex flex-col items-center gap-4 font-body", className), children: [_jsxs("svg", { width: px, height: px, viewBox: `0 0 ${px} ${px}`, "aria-hidden": "true", className: "animate-spin motion-reduce:animate-[spin_3s_linear_infinite]", children: [_jsx("circle", { cx: px / 2, cy: px / 2, r: r, fill: "none", strokeWidth: stroke, className: "stroke-(color:--bg-tertiary)" }), _jsx("circle", { cx: px / 2, cy: px / 2, r: r, fill: "none", strokeWidth: stroke, strokeLinecap: "round", strokeDasharray: `${circumference * 0.25} ${circumference}`, className: "stroke-fg-brand-primary" })] }), label === false ? (_jsx("span", { className: "sr-only", children: defaultStrings.loading })) : (_jsx("span", { className: "text-sm font-medium text-secondary", children: label ?? defaultStrings.loading }))] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type StreamEventType, type StreamMotion } from "../internal/stream.js";
|
|
3
|
+
import { type IconName } from "./Icon.js";
|
|
4
|
+
export interface StreamAlertProps {
|
|
5
|
+
/**
|
|
6
|
+
* Tipo de evento: decide la etiqueta, el icono y el texto por defecto.
|
|
7
|
+
* @default "follow"
|
|
8
|
+
*/
|
|
9
|
+
type?: StreamEventType;
|
|
10
|
+
/** Quien provoca el evento, en Bricolage 24/32 semibold. */
|
|
11
|
+
user: ReactNode;
|
|
12
|
+
/** Cifra del evento: suscripciones regaladas, espectadores del raid, bits o euros donados. */
|
|
13
|
+
amount?: number;
|
|
14
|
+
/** Meses de una renovación. */
|
|
15
|
+
months?: number;
|
|
16
|
+
/** Nivel de una suscripción. */
|
|
17
|
+
tier?: number | string;
|
|
18
|
+
/**
|
|
19
|
+
* Mensaje del espectador. Siempre texto plano: llega del público y se pinta tal cual, nunca como HTML
|
|
20
|
+
* (por eso es `string` y no un nodo). Modéralo antes.
|
|
21
|
+
*/
|
|
22
|
+
message?: string;
|
|
23
|
+
/** Sustituye al texto por defecto del tipo ("Ya no hay vuelta atrás.", "5,00 €"…). */
|
|
24
|
+
detail?: ReactNode;
|
|
25
|
+
/** Sustituye a la etiqueta del tipo ("Nuevo seguidor"…). */
|
|
26
|
+
label?: ReactNode;
|
|
27
|
+
/** Sustituye al icono del tipo. */
|
|
28
|
+
icon?: IconName;
|
|
29
|
+
/** `enter` entra con fundido y 8px de subida en 250 ms; `exit` sale en 200 ms. Con menos movimiento, solo fundido. */
|
|
30
|
+
state?: StreamMotion;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Alerta de un evento del directo en una tarjeta de 480px, siempre en oscuro. */
|
|
34
|
+
export declare function StreamAlert({ type, user, amount, months, tier, message, detail, label, icon, state, className, }: StreamAlertProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "../internal/cx.js";
|
|
3
|
+
import { STREAM_EVENTS, STREAM_ROOT, streamDetail, streamMotion, } from "../internal/stream.js";
|
|
4
|
+
import { Icon } from "./Icon.js";
|
|
5
|
+
/** Alerta de un evento del directo en una tarjeta de 480px, siempre en oscuro. */
|
|
6
|
+
export function StreamAlert({ type = "follow", user, amount, months, tier, message, detail, label, icon, state, className, }) {
|
|
7
|
+
const ev = STREAM_EVENTS[type] ?? STREAM_EVENTS.follow;
|
|
8
|
+
const text = detail ?? streamDetail(type, { amount, months, tier });
|
|
9
|
+
return (_jsxs("div", { role: "status", "aria-live": "polite", className: cx(STREAM_ROOT, "flex w-[480px] max-w-full items-start gap-4 rounded-2xl border border-secondary bg-primary_alt p-6 shadow-xl", streamMotion(state), className), children: [_jsx("span", { className: "inline-flex size-12 flex-none items-center justify-center rounded-xl bg-brand-solid text-white", children: _jsx(Icon, { name: icon ?? ev.icon, size: 24 }) }), _jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("span", { className: "block text-sm font-medium text-tertiary", children: label ?? ev.label }), _jsx("span", { className: "font-display text-display-xs font-semibold wrap-anywhere text-primary", children: user }), text ? _jsx("span", { className: "text-md text-secondary", children: text }) : null, message ? (_jsx("span", { className: "mt-2 rounded-lg bg-tertiary px-3 py-2 text-md wrap-anywhere text-secondary", children: message })) : null] })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type StreamTime } from "../internal/stream.js";
|
|
3
|
+
export type StreamCountdownSize = "md" | "lg" | "xl";
|
|
4
|
+
export interface StreamCountdownProps {
|
|
5
|
+
/** Duración fija en segundos. */
|
|
6
|
+
seconds?: number;
|
|
7
|
+
/** Fecha objetivo; manda sobre `seconds`. */
|
|
8
|
+
target?: StreamTime;
|
|
9
|
+
/** Hora fija: congela la cuenta (pruebas y vistas previas). */
|
|
10
|
+
now?: StreamTime;
|
|
11
|
+
/** Detiene la cuenta. */
|
|
12
|
+
paused?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* `md` 36px · `lg` 60px · `xl` 96px, o un número en px.
|
|
15
|
+
* @default "lg"
|
|
16
|
+
*/
|
|
17
|
+
size?: StreamCountdownSize | number;
|
|
18
|
+
/** Etiqueta debajo; obligatoria en la práctica si hay dos contadores en pantalla. */
|
|
19
|
+
label?: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Solo para StreamScene: etiqueta más grande (`.jm-st-scene-main .jm-st-count-label` del bundle). No es pública. */
|
|
23
|
+
interface CountdownSlots {
|
|
24
|
+
labelClassName?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Cuenta atrás en mm:ss (h:mm:ss si pasa de una hora) que llega a 00:00 y se queda ahí, siempre en oscuro. */
|
|
27
|
+
export declare function StreamCountdown(props: StreamCountdownProps): import("react").JSX.Element;
|
|
28
|
+
/** La cuenta con sus huecos internos; la usa StreamScene. */
|
|
29
|
+
export declare function CountdownView({ size, label, className, labelClassName, ...time }: StreamCountdownProps & CountdownSlots): import("react").JSX.Element;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { cx } from "../internal/cx.js";
|
|
5
|
+
import { STREAM_ROOT, secondsLeft, streamClock, TABULAR } from "../internal/stream.js";
|
|
6
|
+
const SIZE = { md: "text-[36px]", lg: "text-[60px]", xl: "text-[96px]" };
|
|
7
|
+
/**
|
|
8
|
+
* Lógica de la cuenta: un tic por segundo hasta 0, donde se queda. Se reinicia si cambian `seconds`, `target` o
|
|
9
|
+
* `now`; con `now` o `paused` no corre.
|
|
10
|
+
*/
|
|
11
|
+
function useCountdown({ seconds, target, now, paused }) {
|
|
12
|
+
const key = `${target != null ? new Date(target).getTime() : "s"}|${seconds ?? 0}|${now != null ? new Date(now).getTime() : "n"}`;
|
|
13
|
+
const [state, setState] = useState(() => ({ key, left: secondsLeft({ seconds, target, now }) }));
|
|
14
|
+
if (state.key !== key)
|
|
15
|
+
setState({ key, left: secondsLeft({ seconds, target, now }) });
|
|
16
|
+
const frozen = paused || now != null || state.left === 0;
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (frozen)
|
|
19
|
+
return;
|
|
20
|
+
const id = setInterval(() => {
|
|
21
|
+
setState((s) => ({
|
|
22
|
+
key: s.key,
|
|
23
|
+
left: target != null ? secondsLeft({ target }) : Math.max(0, s.left - 1),
|
|
24
|
+
}));
|
|
25
|
+
}, 1000);
|
|
26
|
+
return () => clearInterval(id);
|
|
27
|
+
}, [frozen, target]);
|
|
28
|
+
return state.left;
|
|
29
|
+
}
|
|
30
|
+
/** Cuenta atrás en mm:ss (h:mm:ss si pasa de una hora) que llega a 00:00 y se queda ahí, siempre en oscuro. */
|
|
31
|
+
export function StreamCountdown(props) {
|
|
32
|
+
return _jsx(CountdownView, { ...props });
|
|
33
|
+
}
|
|
34
|
+
/** La cuenta con sus huecos internos; la usa StreamScene. */
|
|
35
|
+
export function CountdownView({ size = "lg", label, className, labelClassName, ...time }) {
|
|
36
|
+
const left = useCountdown(time);
|
|
37
|
+
const custom = typeof size === "number";
|
|
38
|
+
const style = custom ? { fontSize: size } : undefined;
|
|
39
|
+
return (_jsxs("div", { role: "timer", "aria-live": "off", className: cx(STREAM_ROOT, "inline-flex flex-col items-center gap-1", className), children: [_jsx("span", {
|
|
40
|
+
// Con `target` y sin `now`, el servidor y el navegador pueden ir un segundo distintos.
|
|
41
|
+
suppressHydrationWarning: true, className: cx(TABULAR,
|
|
42
|
+
// El tamaño antes que `leading-none`: tailwind-merge quita el interlineado que va antes de un `text-*`.
|
|
43
|
+
custom ? null : SIZE[size], "leading-none font-semibold tracking-[-0.02em] text-primary"), style: style, children: streamClock(left) }), label ? (_jsx("span", { className: cx("text-sm font-medium text-tertiary", labelClassName), children: label })) : null] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type StreamEventType } from "../internal/stream.js";
|
|
3
|
+
export interface StreamEventItem {
|
|
4
|
+
type: StreamEventType;
|
|
5
|
+
user: ReactNode;
|
|
6
|
+
/** Cifra del evento: euros, bits, espectadores del raid o suscripciones regaladas. */
|
|
7
|
+
amount?: number;
|
|
8
|
+
/** Meses de una renovación. */
|
|
9
|
+
months?: number;
|
|
10
|
+
/** Sustituye a la línea generada ("Donación · 5,00 €", "Raid entrante · 1.284"). */
|
|
11
|
+
detail?: ReactNode;
|
|
12
|
+
/** Hora relativa, como "2 min" o "ahora": se entiende al instante, la absoluta obliga a calcular. */
|
|
13
|
+
time?: ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* Clave estable del evento (ampliación sobre la guía). Sin ella se deriva del tipo, el usuario y la cifra, para
|
|
16
|
+
* que con `animate` solo entre la fila nueva cuando llega un evento.
|
|
17
|
+
*/
|
|
18
|
+
id?: string | number;
|
|
19
|
+
}
|
|
20
|
+
export interface StreamEventsProps {
|
|
21
|
+
/** Del más reciente al más antiguo. */
|
|
22
|
+
items: StreamEventItem[];
|
|
23
|
+
/**
|
|
24
|
+
* Filas que se ven. Más de siete deja de ser una lista de un vistazo.
|
|
25
|
+
* @default 5
|
|
26
|
+
*/
|
|
27
|
+
max?: number;
|
|
28
|
+
/** @default "Últimos eventos" */
|
|
29
|
+
title?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Ancho de la lista.
|
|
32
|
+
* @default 360
|
|
33
|
+
*/
|
|
34
|
+
width?: number | string;
|
|
35
|
+
/** La primera fila entra con fundido y 8px de subida al llegar. */
|
|
36
|
+
animate?: boolean;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Lista de los últimos eventos del canal con icono, usuario, detalle y hora relativa, siempre en oscuro. */
|
|
40
|
+
export declare function StreamEvents({ items, max, title, width, animate, className, }: StreamEventsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { cx } from "../internal/cx.js";
|
|
4
|
+
import { keyText, STREAM_EVENTS, STREAM_ROOT, stableKeys, streamMotion, streamShort, } from "../internal/stream.js";
|
|
5
|
+
import { defaultStrings } from "../internal/strings.js";
|
|
6
|
+
import { Icon } from "./Icon.js";
|
|
7
|
+
function contentKey(it) {
|
|
8
|
+
return `${it.type}|${keyText(it.user)}|${it.amount ?? ""}|${it.months ?? ""}`;
|
|
9
|
+
}
|
|
10
|
+
/** Lista de los últimos eventos del canal con icono, usuario, detalle y hora relativa, siempre en oscuro. */
|
|
11
|
+
export function StreamEvents({ items, max = 5, title, width, animate = false, className, }) {
|
|
12
|
+
const titleId = useId();
|
|
13
|
+
const visible = items.slice(0, max);
|
|
14
|
+
// Clave por contenido (y no por posición): al entrar un evento arriba, las demás filas se conservan.
|
|
15
|
+
const keys = stableKeys(visible, contentKey);
|
|
16
|
+
return (_jsxs("div", { className: cx(STREAM_ROOT, "max-w-full overflow-hidden rounded-xl border border-secondary bg-primary_alt", width == null && "w-[360px]", className), style: width == null ? undefined : { width }, children: [_jsx("div", { id: titleId, className: "border-b border-secondary px-4 py-3 text-sm font-semibold text-tertiary", children: title ?? defaultStrings.latestEvents }), _jsx("ul", { "aria-labelledby": titleId, className: "m-0 list-none p-0", children: visible.map((it, i) => {
|
|
17
|
+
const ev = STREAM_EVENTS[it.type] ?? STREAM_EVENTS.follow;
|
|
18
|
+
return (_jsxs("li", { className: cx("flex items-center gap-3 border-b border-secondary px-4 py-3 last:border-b-0", i === 0 && animate && streamMotion("enter")), children: [_jsx("span", { className: "inline-flex size-8 flex-none items-center justify-center rounded-lg bg-tertiary text-fg-secondary", children: _jsx(Icon, { name: ev.icon, size: 16 }) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate text-md font-semibold text-primary", children: it.user }), _jsx("span", { className: "truncate text-sm text-tertiary", children: it.detail ?? streamShort(it.type, it) })] }), it.time != null ? (_jsx("span", { className: "flex-none text-xs text-quaternary", children: it.time })) : null] }, keys[i]));
|
|
19
|
+
}) })] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface StreamGoalLabels {
|
|
3
|
+
/** Pie al llegar a la meta. @default "Meta conseguida. Toca poner otra." */
|
|
4
|
+
done: string;
|
|
5
|
+
/** Pie mientras falta: recibe la cifra con su unidad ("716", "40 €"). */
|
|
6
|
+
remaining: (missing: string) => string;
|
|
7
|
+
}
|
|
8
|
+
export interface StreamGoalProps {
|
|
9
|
+
/** Nombre de la meta; también nombra la barra. */
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
current: number;
|
|
12
|
+
goal: number;
|
|
13
|
+
/** Se añade a las cifras ("subs", "€"). */
|
|
14
|
+
unit?: string;
|
|
15
|
+
/** @default 420 */
|
|
16
|
+
width?: number | string;
|
|
17
|
+
/** Sustituye los textos del pie (ampliación sobre la guía). */
|
|
18
|
+
labels?: Partial<StreamGoalLabels>;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Barra de meta del directo con el avance, lo que falta y el porcentaje, siempre en oscuro. */
|
|
22
|
+
export declare function StreamGoal({ label, current, goal, unit, width, labels, className }: StreamGoalProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { cx } from "../internal/cx.js";
|
|
4
|
+
import { formatNumber } from "../internal/format.js";
|
|
5
|
+
import { STREAM_ROOT, TABULAR } from "../internal/stream.js";
|
|
6
|
+
import { defaultStrings } from "../internal/strings.js";
|
|
7
|
+
/** Barra de meta del directo con el avance, lo que falta y el porcentaje, siempre en oscuro. */
|
|
8
|
+
export function StreamGoal({ label, current, goal, unit, width, labels, className }) {
|
|
9
|
+
const labelId = useId();
|
|
10
|
+
const cur = current || 0;
|
|
11
|
+
const max = goal > 0 ? goal : 1;
|
|
12
|
+
const pct = Math.min(100, Math.max(0, (cur / max) * 100));
|
|
13
|
+
const done = cur >= max;
|
|
14
|
+
const u = unit ? ` ${unit}` : "";
|
|
15
|
+
const t = {
|
|
16
|
+
done: defaultStrings.goalDone,
|
|
17
|
+
remaining: defaultStrings.goalRemaining,
|
|
18
|
+
...labels,
|
|
19
|
+
};
|
|
20
|
+
return (_jsxs("div", { className: cx(STREAM_ROOT, "max-w-full rounded-xl border border-secondary bg-primary_alt px-5 py-4", width == null && "w-[420px]", className), style: width == null ? undefined : { width }, children: [_jsxs("div", { className: "flex items-baseline justify-between gap-4", children: [_jsx("span", { id: labelId, className: "text-md font-semibold text-primary", children: label }), _jsxs("span", { className: cx(TABULAR, "text-sm text-tertiary"), children: [_jsx("b", { className: "font-semibold text-primary", children: formatNumber(cur) }), ` / ${formatNumber(max)}${u}`] })] }), _jsx("div", { role: "progressbar", "aria-labelledby": labelId, "aria-valuemin": 0, "aria-valuemax": max, "aria-valuenow": Math.min(Math.max(cur, 0), max), className: "mt-3 mb-2 h-3.5 overflow-hidden rounded-full bg-quaternary", children: _jsx("div", { className: "h-full rounded-[inherit] bg-brand-solid transition-[width] duration-300 ease-[ease] motion-reduce:transition-none", style: { width: `${pct}%` } }) }), _jsxs("div", { className: cx(TABULAR, "flex justify-between gap-4 text-sm text-tertiary"), children: [_jsx("span", { children: done ? t.done : t.remaining(`${formatNumber(max - cur)}${u}`) }), _jsx("span", { children: `${Math.floor(pct)} %` })] })] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type StreamTime } from "../internal/stream.js";
|
|
3
|
+
export type StreamSceneVariant = "starting" | "brb" | "ending" | "offline";
|
|
4
|
+
export interface StreamSceneProps {
|
|
5
|
+
/** @default "starting" */
|
|
6
|
+
variant?: StreamSceneVariant;
|
|
7
|
+
/** Titular en Bricolage a 152px; no más de 24 caracteres. */
|
|
8
|
+
title?: ReactNode;
|
|
9
|
+
/** `false` lo oculta. */
|
|
10
|
+
subtitle?: ReactNode | false;
|
|
11
|
+
/** Texto de la insignia de estado. */
|
|
12
|
+
badge?: ReactNode;
|
|
13
|
+
/** Cuenta atrás en segundos (solo en `starting` y `brb`). */
|
|
14
|
+
seconds?: number;
|
|
15
|
+
/** Fecha objetivo de la cuenta atrás (solo en `starting` y `brb`). */
|
|
16
|
+
target?: StreamTime;
|
|
17
|
+
/** Hora fija para la cuenta atrás (pruebas). */
|
|
18
|
+
now?: StreamTime;
|
|
19
|
+
paused?: boolean;
|
|
20
|
+
countdownLabel?: ReactNode;
|
|
21
|
+
/** @default "Tu canal" */
|
|
22
|
+
channel?: ReactNode;
|
|
23
|
+
/** Línea central del pie, como la próxima fecha. Una sola información de horario. */
|
|
24
|
+
next?: ReactNode;
|
|
25
|
+
/** @default "twitch.tv/tucanal" */
|
|
26
|
+
handle?: ReactNode;
|
|
27
|
+
/** Mide `1920 × scale`. Sin él se ajusta al ancho del contenedor (en OBS, a 1920, queda 1:1). */
|
|
28
|
+
scale?: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Escena de 1920×1080 para los momentos sin juego (empezando, pausa, final, offline), siempre en oscuro. Es de
|
|
33
|
+
* servidor: solo el lienzo (que mide su contenedor sin `scale`) y la cuenta atrás son de cliente.
|
|
34
|
+
*/
|
|
35
|
+
export declare function StreamScene({ variant, title, subtitle, badge, seconds, target, now, paused, countdownLabel, channel, next, handle, scale, className, }: StreamSceneProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { streamGrid } from "../internal/stream.js";
|
|
3
|
+
import { StreamStage } from "../internal/stream-stage.js";
|
|
4
|
+
import { CountdownView } from "./StreamCountdown.js";
|
|
5
|
+
// Textos por defecto: una broma seca por escena y ninguna más (ficha).
|
|
6
|
+
const SCENES = {
|
|
7
|
+
starting: {
|
|
8
|
+
badge: "Empezamos pronto",
|
|
9
|
+
title: "Ya casi",
|
|
10
|
+
sub: "Últimos retoques. Mientras, el chat puede ir calentando.",
|
|
11
|
+
},
|
|
12
|
+
brb: { badge: "Pausa", title: "Vuelvo enseguida", sub: "Hidratación, no drama." },
|
|
13
|
+
ending: {
|
|
14
|
+
badge: "Fin del directo",
|
|
15
|
+
title: "Hasta la próxima",
|
|
16
|
+
sub: "Gracias por quedarte. Ahora toca dormir como personas.",
|
|
17
|
+
},
|
|
18
|
+
offline: {
|
|
19
|
+
badge: "Sin directo",
|
|
20
|
+
title: "Ahora mismo, silencio",
|
|
21
|
+
sub: "Aquí no emite nadie. Mira el horario o vuelve más tarde.",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const GRID = streamGrid(96, "70% 65%");
|
|
25
|
+
/**
|
|
26
|
+
* Escena de 1920×1080 para los momentos sin juego (empezando, pausa, final, offline), siempre en oscuro. Es de
|
|
27
|
+
* servidor: solo el lienzo (que mide su contenedor sin `scale`) y la cuenta atrás son de cliente.
|
|
28
|
+
*/
|
|
29
|
+
export function StreamScene({ variant = "starting", title, subtitle, badge, seconds, target, now, paused, countdownLabel, channel, next, handle, scale, className, }) {
|
|
30
|
+
const d = SCENES[variant] ?? SCENES.starting;
|
|
31
|
+
const countdown = (variant === "starting" || variant === "brb") && (seconds != null || target != null);
|
|
32
|
+
return (_jsxs(StreamStage, { width: 1920, height: 1080, scale: scale, className: className, children: [_jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0", style: GRID }), _jsxs("div", { className: "absolute inset-[0_0_128px_0] flex flex-col items-center justify-center gap-8 px-[240px] text-center", children: [_jsxs("span", { className: "inline-flex h-14 items-center gap-3.5 rounded-full border-2 border-primary bg-primary_alt px-7 text-[28px] leading-9 font-medium text-secondary", children: [_jsx("span", { "aria-hidden": "true", className: "inline-block size-3.5 flex-none rounded-full bg-fg-brand-primary" }), badge ?? d.badge] }), _jsx("h1", { className: "m-0 font-display text-[152px] leading-[1.04] font-semibold tracking-[-0.02em] text-primary", children: title ?? d.title }), subtitle !== false ? (_jsx("p", { className: "m-0 max-w-[1040px] text-[40px] leading-14 text-tertiary", children: subtitle ?? d.sub })) : null, countdown ? (_jsx(CountdownView, { seconds: seconds, target: target, now: now, paused: paused, size: 176, label: countdownLabel, className: "mt-4 gap-2", labelClassName: "text-[28px] leading-9" })) : null] }), _jsxs("div", { className: "absolute right-20 bottom-14 left-20 flex items-center justify-between gap-10 text-[28px] leading-9 font-medium text-tertiary", children: [_jsx("span", { className: "font-display font-semibold text-primary", children: channel ?? "Tu canal" }), next ? _jsx("span", { children: next }) : null, _jsx("span", { children: handle ?? "twitch.tv/tucanal" })] })] }));
|
|
33
|
+
}
|