@vitavision/ui 0.8.0 → 0.9.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 CHANGED
@@ -127,7 +127,8 @@ keep independent preferences.
127
127
  `SkeletonRows` · `Field` · `Input`, `NumberInput` (with an optional `unit` written in the
128
128
  field), `Textarea` · `VectorInput`, `PoseInput` · `PageHeader`, `Panel`,
129
129
  `ReadoutStrip`, `Section` · `SegmentedControl` · `Select` · `Slider` · `Table` ·
130
- `Checkbox`, `Switch` · `ToggleChip` · `InfoHint`, `Tooltip`, `TooltipProvider` ·
130
+ `Checkbox`, `Switch` · `ToggleChip` · `Toaster`, `toast()` (notifications from anywhere; mount one `<Toaster />` near the root) ·
131
+ `InfoHint`, `Tooltip`, `TooltipProvider` ·
131
132
  `ThemeToggle` · plus `cn`, `focusRing`, `focusRingInset`.
132
133
 
133
134
  `PoseInput` edits an SE(3) pose in the wire form `{ rotation: [qx, qy, qz, qw], translation:
package/dist/index.d.ts CHANGED
@@ -880,6 +880,97 @@ export declare function ToggleChip({ checked, onCheckedChange, children, swatch,
880
880
  className?: string | undefined;
881
881
  }): import("react").JSX.Element;
882
882
  //#endregion
883
+ //#region src/components/ui/toastStore.d.ts
884
+ /** A toast's kind: a fact (`info`), a completed action (`success`), a caveat (`warn`), a failure (`error`). */
885
+ type ToastTone = "info" | "success" | "warn" | "error";
886
+ /** What `toast()` takes. */
887
+ interface ToastOptions {
888
+ /** One line: what happened. */
889
+ title: string;
890
+ /** More detail, and what to do about it. */
891
+ description?: string | undefined;
892
+ /** Defaults to `info`. */
893
+ tone?: ToastTone | undefined;
894
+ /**
895
+ * How long it stays, in milliseconds, while not hovered or focused. Defaults to 5000
896
+ * (8000 for `error`); `Infinity` keeps it until dismissed.
897
+ */
898
+ duration?: number | undefined;
899
+ /** Replace the toast with this id instead of adding one — for progress that updates in place. */
900
+ id?: string | undefined;
901
+ }
902
+ /** A toast in the store. */
903
+ interface ToastRecord {
904
+ /** Identifies the toast to `dismiss` and to `id` replacement. */
905
+ id: string;
906
+ /** One line: what happened. */
907
+ title: string;
908
+ /** More detail, if any. */
909
+ description: string | undefined;
910
+ /** Its kind. */
911
+ tone: ToastTone;
912
+ /** Milliseconds before it dismisses itself (`Infinity`: never). */
913
+ duration: number;
914
+ }
915
+ /**
916
+ * A toast store: what `toast()` writes to and `<Toaster />` reads. Every member may be called
917
+ * detached.
918
+ */
919
+ interface ToastStore {
920
+ /** Add a toast (or replace the one with `options.id`), returning its id. */
921
+ readonly toast: (options: ToastOptions) => string;
922
+ /** Remove one toast, or every toast when `id` is omitted. */
923
+ readonly dismiss: (id?: string) => void;
924
+ /** The toasts, oldest first. The same array until the store changes. */
925
+ readonly getSnapshot: () => readonly ToastRecord[];
926
+ /** Be told when the toasts change; returns the unsubscribe function. */
927
+ readonly subscribe: (listener: () => void) => () => void;
928
+ }
929
+ /**
930
+ * A new, empty toast store. The package keeps one (behind `toast()` and the default
931
+ * `<Toaster />`); create another only to isolate a region or a test.
932
+ *
933
+ * @returns The store.
934
+ */
935
+ export declare function createToastStore(): ToastStore;
936
+ /** The store behind `toast()` and a `<Toaster />` given no `store`. */
937
+ export declare const defaultToastStore: ToastStore;
938
+ /**
939
+ * Raise a notification, from anywhere — shown by the mounted `<Toaster />`.
940
+ *
941
+ * `toast({ title: "Scenario loaded", tone: "success" })`. Pass `id` to update a toast in
942
+ * place (a progress message that becomes a result). `toast.dismiss(id)` removes one early,
943
+ * `toast.dismiss()` all of them.
944
+ *
945
+ * @param options - The message, its tone and its duration.
946
+ * @returns The toast's id.
947
+ */
948
+ export declare const toast: ((options: ToastOptions) => string) & {
949
+ /** Remove one toast, or every toast when `id` is omitted. */
950
+ dismiss: (id?: string) => void;
951
+ };
952
+ //#endregion
953
+ //#region src/components/ui/Toaster.d.ts
954
+ /** Props of `Toaster`. */
955
+ interface ToasterProps {
956
+ /** The store to show. Defaults to the one behind `toast()`. */
957
+ store?: ToastStore | undefined;
958
+ /** How many toasts are shown at once; older ones wait. Defaults to 5. */
959
+ limit?: number | undefined;
960
+ /** Merged with the stack's own classes through `cn` (it is fixed to the bottom-right corner). */
961
+ className?: string | undefined;
962
+ }
963
+ /**
964
+ * The stack of notifications raised with `toast()`. Mount it once, near the root.
965
+ *
966
+ * Fixed to the bottom-right corner, newest at the bottom. Each toast dismisses itself after
967
+ * its `duration`; the countdown pauses while the pointer is over the stack or focus is inside
968
+ * it. A `section` landmark ("Notifications") holding a polite live region, so new toasts are
969
+ * announced; an `error` toast is a `role="alert"`, announced at once. Each toast carries
970
+ * `data-tone` and a "Dismiss" button.
971
+ */
972
+ export declare function Toaster({ store, limit, className }: ToasterProps): import("react").JSX.Element;
973
+ //#endregion
883
974
  //#region src/components/ui/Tooltip.d.ts
884
975
  /**
885
976
  * Shares one open delay (200 ms) across the tooltips below it. Mount it once near the root:
@@ -999,5 +1090,5 @@ export declare function Tabs<Id extends string>({ items, active, onSelect, label
999
1090
  className?: string | undefined;
1000
1091
  }): import("react").JSX.Element;
1001
1092
  //#endregion
1002
- export type { BackLink, ButtonSize, ButtonVariant, CalloutTone, Column, Density, MeasureTone, NumberInputProps, PoseInputProps, PoseValue, Quaternion, ReadoutItem, RotationView, SelectOption, TabItem, ThemeChoice, Tone, Vec3, VectorInputProps };
1093
+ export type { BackLink, ButtonSize, ButtonVariant, CalloutTone, Column, Density, MeasureTone, NumberInputProps, PoseInputProps, PoseValue, Quaternion, ReadoutItem, RotationView, SelectOption, TabItem, ThemeChoice, ToastOptions, ToastRecord, ToastStore, ToastTone, ToasterProps, Tone, Vec3, VectorInputProps };
1003
1094
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, XCircle } from "lucide-react";
2
- import { createContext, isValidElement, use, useCallback, useId, useMemo, useState, useSyncExternalStore } from "react";
1
+ import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, CircleCheck, CircleX, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, TriangleAlert, X, XCircle } from "lucide-react";
2
+ import { createContext, isValidElement, use, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import * as RadixTooltip from "@radix-ui/react-tooltip";
4
4
  import { clsx } from "clsx";
5
5
  import { twMerge } from "tailwind-merge";
@@ -1558,6 +1558,187 @@ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = fal
1558
1558
  });
1559
1559
  }
1560
1560
  //#endregion
1561
+ //#region src/components/ui/toastStore.ts
1562
+ const DEFAULT_DURATION = 5e3;
1563
+ const ERROR_DURATION = 8e3;
1564
+ /**
1565
+ * A new, empty toast store. The package keeps one (behind `toast()` and the default
1566
+ * `<Toaster />`); create another only to isolate a region or a test.
1567
+ *
1568
+ * @returns The store.
1569
+ */
1570
+ function createToastStore() {
1571
+ let toasts = [];
1572
+ let sequence = 0;
1573
+ const listeners = /* @__PURE__ */ new Set();
1574
+ const commit = (next) => {
1575
+ toasts = next;
1576
+ for (const listener of [...listeners]) listener();
1577
+ };
1578
+ return {
1579
+ toast: (options) => {
1580
+ const tone = options.tone ?? "info";
1581
+ sequence += 1;
1582
+ const record = {
1583
+ id: options.id ?? `toast-${sequence}`,
1584
+ title: options.title,
1585
+ description: options.description,
1586
+ tone,
1587
+ duration: options.duration ?? (tone === "error" ? ERROR_DURATION : DEFAULT_DURATION)
1588
+ };
1589
+ const existing = toasts.findIndex((t) => t.id === record.id);
1590
+ commit(existing === -1 ? [...toasts, record] : toasts.map((t, i) => i === existing ? record : t));
1591
+ return record.id;
1592
+ },
1593
+ dismiss: (id) => {
1594
+ if (id === void 0) {
1595
+ if (toasts.length > 0) commit([]);
1596
+ return;
1597
+ }
1598
+ if (toasts.some((t) => t.id === id)) commit(toasts.filter((t) => t.id !== id));
1599
+ },
1600
+ getSnapshot: () => toasts,
1601
+ subscribe: (listener) => {
1602
+ listeners.add(listener);
1603
+ return () => {
1604
+ listeners.delete(listener);
1605
+ };
1606
+ }
1607
+ };
1608
+ }
1609
+ /** The store behind `toast()` and a `<Toaster />` given no `store`. */
1610
+ const defaultToastStore = createToastStore();
1611
+ /**
1612
+ * Raise a notification, from anywhere — shown by the mounted `<Toaster />`.
1613
+ *
1614
+ * `toast({ title: "Scenario loaded", tone: "success" })`. Pass `id` to update a toast in
1615
+ * place (a progress message that becomes a result). `toast.dismiss(id)` removes one early,
1616
+ * `toast.dismiss()` all of them.
1617
+ *
1618
+ * @param options - The message, its tone and its duration.
1619
+ * @returns The toast's id.
1620
+ */
1621
+ const toast = Object.assign((options) => defaultToastStore.toast(options), { dismiss: (id) => defaultToastStore.dismiss(id) });
1622
+ //#endregion
1623
+ //#region src/components/ui/Toaster.tsx
1624
+ const TONE = {
1625
+ info: {
1626
+ border: "border-signal/30",
1627
+ icon: "text-signal",
1628
+ Icon: Info
1629
+ },
1630
+ success: {
1631
+ border: "border-normal/30",
1632
+ icon: "text-normal",
1633
+ Icon: CircleCheck
1634
+ },
1635
+ warn: {
1636
+ border: "border-warn/30",
1637
+ icon: "text-warn",
1638
+ Icon: TriangleAlert
1639
+ },
1640
+ error: {
1641
+ border: "border-defect/30",
1642
+ icon: "text-defect",
1643
+ Icon: CircleX
1644
+ }
1645
+ };
1646
+ const NO_TOASTS = [];
1647
+ /**
1648
+ * The stack of notifications raised with `toast()`. Mount it once, near the root.
1649
+ *
1650
+ * Fixed to the bottom-right corner, newest at the bottom. Each toast dismisses itself after
1651
+ * its `duration`; the countdown pauses while the pointer is over the stack or focus is inside
1652
+ * it. A `section` landmark ("Notifications") holding a polite live region, so new toasts are
1653
+ * announced; an `error` toast is a `role="alert"`, announced at once. Each toast carries
1654
+ * `data-tone` and a "Dismiss" button.
1655
+ */
1656
+ function Toaster({ store = defaultToastStore, limit = 5, className }) {
1657
+ const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot, () => NO_TOASTS);
1658
+ const [hovered, setHovered] = useState(false);
1659
+ const [focused, setFocused] = useState(false);
1660
+ const shown = toasts.slice(-Math.max(1, limit));
1661
+ const onBlur = (event) => {
1662
+ if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
1663
+ };
1664
+ return /* @__PURE__ */ jsx("section", {
1665
+ "aria-label": "Notifications",
1666
+ onPointerEnter: () => setHovered(true),
1667
+ onPointerLeave: () => setHovered(false),
1668
+ onFocus: () => setFocused(true),
1669
+ onBlur,
1670
+ className: cn("pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]", className),
1671
+ children: /* @__PURE__ */ jsx("ol", {
1672
+ "aria-live": "polite",
1673
+ className: "flex flex-col gap-2",
1674
+ children: shown.map((record) => /* @__PURE__ */ jsx(ToastItem, {
1675
+ record,
1676
+ paused: hovered || focused,
1677
+ onDismiss: () => store.dismiss(record.id)
1678
+ }, record.id))
1679
+ })
1680
+ });
1681
+ }
1682
+ function ToastItem({ record, paused, onDismiss }) {
1683
+ const { border, icon, Icon } = TONE[record.tone];
1684
+ const remainingRef = useRef({
1685
+ record,
1686
+ ms: record.duration
1687
+ });
1688
+ const dismissRef = useRef(onDismiss);
1689
+ useEffect(() => {
1690
+ dismissRef.current = onDismiss;
1691
+ });
1692
+ useEffect(() => {
1693
+ if (remainingRef.current.record !== record) remainingRef.current = {
1694
+ record,
1695
+ ms: record.duration
1696
+ };
1697
+ if (paused || !Number.isFinite(remainingRef.current.ms)) return;
1698
+ const started = Date.now();
1699
+ const timer = setTimeout(() => dismissRef.current(), remainingRef.current.ms);
1700
+ return () => {
1701
+ clearTimeout(timer);
1702
+ remainingRef.current.ms = Math.max(0, remainingRef.current.ms - (Date.now() - started));
1703
+ };
1704
+ }, [paused, record]);
1705
+ return /* @__PURE__ */ jsx("li", {
1706
+ className: "pointer-events-auto",
1707
+ children: /* @__PURE__ */ jsxs("div", {
1708
+ role: record.tone === "error" ? "alert" : void 0,
1709
+ "data-tone": record.tone,
1710
+ className: cn("flex items-start gap-2.5 rounded-panel border bg-overlay px-3 py-2.5 text-sm leading-snug shadow-lg shadow-black/25", "transition-[opacity,translate] duration-200 starting:translate-y-2 starting:opacity-0", border),
1711
+ children: [
1712
+ /* @__PURE__ */ jsx(Icon, {
1713
+ className: cn("mt-0.5 size-4 shrink-0", icon),
1714
+ "aria-hidden": true
1715
+ }),
1716
+ /* @__PURE__ */ jsxs("div", {
1717
+ className: "flex min-w-0 flex-1 flex-col gap-0.5",
1718
+ children: [/* @__PURE__ */ jsx("p", {
1719
+ className: "font-medium text-fg",
1720
+ children: record.title
1721
+ }), record.description && /* @__PURE__ */ jsx("p", {
1722
+ className: "text-fg-muted",
1723
+ children: record.description
1724
+ })]
1725
+ }),
1726
+ /* @__PURE__ */ jsx("button", {
1727
+ type: "button",
1728
+ "aria-label": "Dismiss",
1729
+ title: "Dismiss",
1730
+ onClick: onDismiss,
1731
+ className: cn("-mr-1 grid size-6 shrink-0 place-items-center rounded-control text-fg-subtle transition-colors hover:bg-raised hover:text-fg", focusRing),
1732
+ children: /* @__PURE__ */ jsx(X, {
1733
+ className: "size-3.5",
1734
+ "aria-hidden": true
1735
+ })
1736
+ })
1737
+ ]
1738
+ })
1739
+ });
1740
+ }
1741
+ //#endregion
1561
1742
  //#region src/components/Tabs.tsx
1562
1743
  const NEXT_KEYS = {
1563
1744
  ArrowRight: (index, length) => (index + 1) % length,
@@ -1620,6 +1801,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
1620
1801
  });
1621
1802
  }
1622
1803
  //#endregion
1623
- export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toneColor, useControlHeight, useDensity };
1804
+ export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toast, toneColor, useControlHeight, useDensity };
1624
1805
 
1625
1806
  //# sourceMappingURL=index.js.map