@dtcode/ui 0.2.0 → 0.4.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.
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from "./cn.js";
3
+ import { controlClasses } from "./input.js";
4
+ /** Campo de texto longo, com o visual do Input. Use dentro de <Field> para rótulo, dica e erro. */
5
+ export function Textarea({ invalid, className, rows = 4, ...props }) {
6
+ return (_jsx("textarea", { rows: rows, "aria-invalid": invalid || undefined, className: controlClasses({ invalid, className: cn("min-h-20 resize-y py-2.5 leading-relaxed", className) }), ...props }));
7
+ }
@@ -0,0 +1,11 @@
1
+ export type Theme = "light" | "dark" | "system";
2
+ export declare const DARK = "(prefers-color-scheme: dark)";
3
+ /**
4
+ * Script para o <head>, antes de qualquer CSS: aplica o tema salvo antes da primeira pintura
5
+ * (sem piscar o tema errado). No Next.js, use <script dangerouslySetInnerHTML={{ __html: themeScript() }} />;
6
+ * no Vite, cole o retorno num <script> do index.html.
7
+ */
8
+ export declare function themeScript({ defaultTheme, storageKey }?: {
9
+ defaultTheme?: Theme;
10
+ storageKey?: string;
11
+ }): string;
@@ -0,0 +1,10 @@
1
+ // Fora do arquivo "use client" do tema: o script é montado no servidor (layout do Next.js) ou no build.
2
+ export const DARK = "(prefers-color-scheme: dark)";
3
+ /**
4
+ * Script para o <head>, antes de qualquer CSS: aplica o tema salvo antes da primeira pintura
5
+ * (sem piscar o tema errado). No Next.js, use <script dangerouslySetInnerHTML={{ __html: themeScript() }} />;
6
+ * no Vite, cole o retorno num <script> do index.html.
7
+ */
8
+ export function themeScript({ defaultTheme = "light", storageKey = "theme" } = {}) {
9
+ return `(function(){try{var t=localStorage.getItem(${JSON.stringify(storageKey)});if(t!=="light"&&t!=="dark"&&t!=="system")t=${JSON.stringify(defaultTheme)};if(t==="system")t=matchMedia(${JSON.stringify(DARK)}).matches?"dark":"light";var r=document.documentElement;r.dataset.theme=t;r.style.colorScheme=t}catch(e){}})()`;
10
+ }
@@ -0,0 +1,24 @@
1
+ import { type Theme } from "./theme-script.js";
2
+ export type { Theme };
3
+ type Resolved = "light" | "dark";
4
+ type ThemeApi = {
5
+ /** Escolha salva. undefined até o navegador ler a preferência (no servidor e na hidratação). */
6
+ theme: Theme | undefined;
7
+ /** Tema aplicado de fato ("system" vira light ou dark). */
8
+ resolvedTheme: Resolved | undefined;
9
+ setTheme: (theme: Theme) => void;
10
+ };
11
+ /**
12
+ * Tema claro/escuro para qualquer app React (Next.js, Vite…). Grava a escolha no localStorage e
13
+ * aplica data-theme no <html>, que é o que os tokens do styles.css usam.
14
+ */
15
+ export declare function ThemeProvider({ children, defaultTheme, storageKey, disableTransitionOnChange, }: {
16
+ children: React.ReactNode;
17
+ defaultTheme?: Theme;
18
+ storageKey?: string;
19
+ /** Desliga as transições no instante da troca, para nada animar de uma cor para a outra. */
20
+ disableTransitionOnChange?: boolean;
21
+ }): import("react").JSX.Element;
22
+ export declare function useTheme(): ThemeApi;
23
+ /** Botão que alterna entre claro e escuro. */
24
+ export declare function ThemeToggle({ className, ...props }: Omit<React.ComponentProps<"button">, "onClick" | "children">): import("react").JSX.Element;
package/dist/theme.js ADDED
@@ -0,0 +1,91 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
4
+ import { cn } from "./cn.js";
5
+ import { Icon } from "./icon.js";
6
+ import { DARK } from "./theme-script.js";
7
+ const ThemeContext = createContext(null);
8
+ // Avisa as outras instâncias na mesma aba (o evento "storage" só chega às outras abas).
9
+ const CHANGE = "dtcode-theme-change";
10
+ function applyTheme(resolved, disableTransition) {
11
+ const root = document.documentElement;
12
+ if (root.dataset.theme === resolved)
13
+ return;
14
+ let style;
15
+ if (disableTransition) {
16
+ style = document.createElement("style");
17
+ style.textContent = "*,*::before,*::after{transition:none!important}";
18
+ document.head.appendChild(style);
19
+ }
20
+ root.dataset.theme = resolved;
21
+ root.style.colorScheme = resolved;
22
+ if (style) {
23
+ void getComputedStyle(root).opacity; // força o recálculo antes de devolver as transições
24
+ const el = style;
25
+ setTimeout(() => el.remove(), 1);
26
+ }
27
+ }
28
+ const subscribeSystem = (onChange) => {
29
+ const mq = matchMedia(DARK);
30
+ mq.addEventListener("change", onChange);
31
+ return () => mq.removeEventListener("change", onChange);
32
+ };
33
+ /**
34
+ * Tema claro/escuro para qualquer app React (Next.js, Vite…). Grava a escolha no localStorage e
35
+ * aplica data-theme no <html>, que é o que os tokens do styles.css usam.
36
+ */
37
+ export function ThemeProvider({ children, defaultTheme = "light", storageKey = "theme", disableTransitionOnChange = true, }) {
38
+ const subscribeStored = useCallback((onChange) => {
39
+ const onStorage = (e) => {
40
+ if (e.key === storageKey)
41
+ onChange();
42
+ };
43
+ window.addEventListener("storage", onStorage);
44
+ window.addEventListener(CHANGE, onChange);
45
+ return () => {
46
+ window.removeEventListener("storage", onStorage);
47
+ window.removeEventListener(CHANGE, onChange);
48
+ };
49
+ }, [storageKey]);
50
+ const readStored = useCallback(() => {
51
+ try {
52
+ return localStorage.getItem(storageKey);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }, [storageKey]);
58
+ const stored = useSyncExternalStore(subscribeStored, readStored, () => undefined);
59
+ const systemDark = useSyncExternalStore(subscribeSystem, () => matchMedia(DARK).matches, () => undefined);
60
+ const theme = stored === undefined ? undefined : stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme;
61
+ const resolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme;
62
+ useEffect(() => {
63
+ if (resolvedTheme)
64
+ applyTheme(resolvedTheme, disableTransitionOnChange);
65
+ }, [resolvedTheme, disableTransitionOnChange]);
66
+ const setTheme = useCallback((next) => {
67
+ try {
68
+ localStorage.setItem(storageKey, next);
69
+ }
70
+ catch {
71
+ // Sem localStorage (modo privado restrito): aplica só nesta página.
72
+ applyTheme(next === "system" ? (matchMedia(DARK).matches ? "dark" : "light") : next, disableTransitionOnChange);
73
+ }
74
+ window.dispatchEvent(new Event(CHANGE));
75
+ }, [storageKey, disableTransitionOnChange]);
76
+ const api = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme, setTheme]);
77
+ return _jsx(ThemeContext.Provider, { value: api, children: children });
78
+ }
79
+ export function useTheme() {
80
+ const api = useContext(ThemeContext);
81
+ if (!api)
82
+ throw new Error("useTheme precisa estar dentro de <ThemeProvider>.");
83
+ return api;
84
+ }
85
+ /** Botão que alterna entre claro e escuro. */
86
+ export function ThemeToggle({ className, ...props }) {
87
+ const { resolvedTheme, setTheme } = useTheme();
88
+ const mounted = resolvedTheme !== undefined;
89
+ const dark = resolvedTheme === "dark";
90
+ return (_jsx("button", { type: "button", onClick: () => setTheme(dark ? "light" : "dark"), "aria-label": dark ? "Usar tema claro" : "Usar tema escuro", className: cn("grid size-9 place-items-center rounded-md border border-border bg-surface text-fg-2 transition-[color,border-color,scale] hover:border-border-strong hover:text-fg active:scale-90", className), ...props, children: _jsx(Icon, { name: dark ? "light_mode" : "dark_mode", size: 18, className: mounted ? "animate-spin-in" : undefined }, dark ? "sol" : "lua") }));
91
+ }
@@ -0,0 +1,24 @@
1
+ export type ToastTone = "success" | "info" | "danger";
2
+ export type ToastOptions = {
3
+ title: React.ReactNode;
4
+ description?: React.ReactNode;
5
+ tone?: ToastTone;
6
+ /** Botão de ação (ex.: Desfazer). Clicar fecha o aviso. */
7
+ action?: {
8
+ label: string;
9
+ onClick: () => void;
10
+ };
11
+ /** Tempo na tela em ms. Padrão: 5 s, ou 8 s quando há ação. Infinity mantém até fechar. */
12
+ duration?: number;
13
+ };
14
+ type ToastApi = {
15
+ toast: (options: ToastOptions) => number;
16
+ dismiss: (id: number) => void;
17
+ };
18
+ /** Avisos rápidos. Envolva o app uma vez e chame useToast() onde precisar. */
19
+ export declare function ToastProvider({ children }: {
20
+ children: React.ReactNode;
21
+ }): import("react").JSX.Element;
22
+ /** toast({ title, tone, action }) mostra um aviso; dismiss(id) fecha antes do tempo. */
23
+ export declare function useToast(): ToastApi;
24
+ export {};
package/dist/toast.js ADDED
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
+ import { cn } from "./cn.js";
5
+ import { Icon } from "./icon.js";
6
+ const ToastContext = createContext(null);
7
+ const tones = {
8
+ success: { icon: "check_circle", cls: "text-success" },
9
+ info: { icon: "info", cls: "text-info" },
10
+ danger: { icon: "error", cls: "text-danger" },
11
+ };
12
+ /** Avisos rápidos. Envolva o app uma vez e chame useToast() onde precisar. */
13
+ export function ToastProvider({ children }) {
14
+ const [items, setItems] = useState([]);
15
+ const next = useRef(0);
16
+ const dismiss = useCallback((id) => setItems((list) => list.filter((t) => t.id !== id)), []);
17
+ const toast = useCallback((options) => {
18
+ const id = ++next.current;
19
+ // No máximo 3 na tela: o mais antigo sai.
20
+ setItems((list) => [...list.slice(-2), { ...options, id }]);
21
+ return id;
22
+ }, []);
23
+ const api = useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
24
+ return (_jsxs(ToastContext.Provider, { value: api, children: [children, _jsx("div", { role: "region", "aria-label": "Avisos", "aria-live": "polite", className: "pointer-events-none fixed right-4 bottom-4 z-[60] grid w-[min(380px,calc(100%-32px))] gap-2", children: items.map((t) => (_jsx(ToastCard, { item: t, dismiss: dismiss }, t.id))) })] }));
25
+ }
26
+ function ToastCard({ item, dismiss }) {
27
+ const tone = tones[item.tone ?? "success"];
28
+ const [paused, setPaused] = useState(false);
29
+ const duration = item.duration ?? (item.action ? 8000 : 5000);
30
+ const { id } = item;
31
+ const onDismiss = () => dismiss(id);
32
+ useEffect(() => {
33
+ if (paused || !Number.isFinite(duration))
34
+ return;
35
+ const timer = setTimeout(() => dismiss(id), duration);
36
+ return () => clearTimeout(timer);
37
+ }, [paused, duration, dismiss, id]);
38
+ return (_jsxs("div", { role: item.tone === "danger" ? "alert" : "status", onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), className: "pointer-events-auto flex animate-fade-up items-start gap-3 rounded-lg border border-border bg-surface p-4 text-sm text-fg shadow-3", children: [_jsx(Icon, { name: tone.icon, size: 20, className: cn("mt-px", tone.cls) }), _jsxs("div", { className: "grid min-w-0 flex-1 gap-0.5", children: [_jsx("strong", { className: "font-semibold", children: item.title }), item.description && _jsx("span", { className: "text-fg-2", children: item.description })] }), item.action && (_jsx("button", { type: "button", onClick: () => {
39
+ item.action?.onClick();
40
+ onDismiss();
41
+ }, className: "flex-none rounded-md px-2 py-1 font-semibold text-link transition-colors hover:bg-surface-subtle", children: item.action.label })), _jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Fechar aviso", className: "-m-1 grid size-7 flex-none place-items-center rounded-md text-muted hover:bg-surface-subtle hover:text-fg", children: _jsx(Icon, { name: "close", size: 18 }) })] }));
42
+ }
43
+ /** toast({ title, tone, action }) mostra um aviso; dismiss(id) fecha antes do tempo. */
44
+ export function useToast() {
45
+ const api = useContext(ToastContext);
46
+ if (!api)
47
+ throw new Error("useToast precisa estar dentro de <ToastProvider>.");
48
+ return api;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtcode/ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Componentes React e tokens do Design System dtcode (Tailwind CSS v4).",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -32,11 +32,15 @@
32
32
  "tailwindcss": ">=4"
33
33
  },
34
34
  "dependencies": {
35
+ "@radix-ui/react-checkbox": "^1.4.1",
36
+ "@radix-ui/react-dialog": "^1.3.1",
37
+ "@radix-ui/react-dropdown-menu": "^2.2.1",
35
38
  "@radix-ui/react-switch": "^1.4.1",
36
39
  "@radix-ui/react-tabs": "^1.2.1",
37
40
  "clsx": "^2.1.1"
38
41
  },
39
42
  "devDependencies": {
43
+ "@material-symbols/font-500": "0.48.1",
40
44
  "@types/react": "^19",
41
45
  "typescript": "^5"
42
46
  }