@kennycorrea/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.
package/README.md CHANGED
@@ -237,3 +237,36 @@ worth doing as its own commit rather than inside this one.
237
237
  ⚠ **A `file:` install was a symlink; a version install is a copy.** Edits here no longer appear in
238
238
  a consumer. An app that needs an unreleased change wants `npm link` or a temporary `file:`
239
239
  override — never a copied file, which is the drift this package exists to end.
240
+
241
+ ## Toasts — 0.3.0
242
+
243
+ Mount `<Toaster />` once, inside `<Theme>`, and call `toast()` from anywhere — it is a factory
244
+ over a module-level store, so each call stacks another toast and returns its id.
245
+
246
+ ```tsx
247
+ <Toaster duration={5000} position="bottom-right" />
248
+
249
+ toast({ title: 'Saved' }); // info by default
250
+ toast.success({ title: 'Booking moved', description: 'Tue 14:30' });
251
+ toast.warning({ title: 'Close to the IVA ceiling', duration: 10_000 });
252
+ const id = toast.error({ title: 'Payment failed', duration: Infinity });
253
+ toast.info({ title: 'Cancelled', action: { label: 'Undo', onClick: restore } });
254
+ toast.dismiss(id); // or toast.dismiss() for all
255
+ ```
256
+
257
+ `duration` overrides the `Toaster` default per toast. An action closes the toast after its
258
+ `onClick` unless `keepOpen` is set. Passing an existing `id` replaces that toast in place.
259
+ The close button's name comes from `UiStrings.dismiss`, so feed it through `UiStringsProvider`.
260
+
261
+ ## Slider — 0.4.0
262
+
263
+ A thin wrap of Radix Themes' `Slider` at size `2`, like the other atoms. `label` is required and
264
+ becomes the `aria-label`, because a slider has no visible text of its own.
265
+
266
+ ```tsx
267
+ <Slider label="Reminder lead time (hours)" min={1} max={48} step={1}
268
+ value={[hours]} onValueChange={([h]) => setHours(h)} />
269
+ <Slider label="Price range" defaultValue={[10, 60]} min={0} max={200} /> // two thumbs = range
270
+ ```
271
+
272
+ Values are arrays, as in Radix: one entry per thumb.
@@ -31,6 +31,10 @@ declare const PATHS: {
31
31
  readonly 'close-circle': "M12 21a9 9 0 100-18 9 9 0 000 18zM9.5 9.5l5 5M14.5 9.5l-5 5";
32
32
  readonly 'chevron-up': "M6 15l6-6 6 6";
33
33
  readonly 'chevron-down': "M6 9l6 6 6-6";
34
+ readonly 'check-circle': "M12 21a9 9 0 100-18 9 9 0 000 18zM8.5 12.5l2.5 2.5 4.5-5";
35
+ readonly warning: "M12 3l10 18H2zM12 10v4M12 17.5v.01";
36
+ readonly error: "M12 21a9 9 0 100-18 9 9 0 000 18zM12 7.5v5M12 16v.01";
37
+ readonly info: "M12 21a9 9 0 100-18 9 9 0 000 18zM12 11v5M12 8v.01";
34
38
  };
35
39
  export type IconName = keyof typeof PATHS;
36
40
  export interface IconProps {
@@ -37,6 +37,11 @@ const PATHS = {
37
37
  // section both point along the axis the content moves in.
38
38
  'chevron-up': 'M6 15l6-6 6 6',
39
39
  'chevron-down': 'M6 9l6 6 6-6',
40
+ // The four toast types: each a shape that reads without its colour.
41
+ 'check-circle': 'M12 21a9 9 0 100-18 9 9 0 000 18zM8.5 12.5l2.5 2.5 4.5-5',
42
+ warning: 'M12 3l10 18H2zM12 10v4M12 17.5v.01',
43
+ error: 'M12 21a9 9 0 100-18 9 9 0 000 18zM12 7.5v5M12 16v.01',
44
+ info: 'M12 21a9 9 0 100-18 9 9 0 000 18zM12 11v5M12 8v.01',
40
45
  };
41
46
  export function Icon({ name, className, label }) {
42
47
  return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round", className: cn('size-5 shrink-0', className), "aria-hidden": label ? undefined : true, role: label ? 'img' : undefined, "aria-label": label, children: _jsx("path", { d: PATHS[name] }) }));
@@ -0,0 +1,10 @@
1
+ import { Slider as ThemedSlider } from '@radix-ui/themes';
2
+ import { type ComponentPropsWithoutRef } from 'react';
3
+ export interface SliderProps extends Omit<ComponentPropsWithoutRef<typeof ThemedSlider>, 'size' | 'aria-label'> {
4
+ /**
5
+ * Required: a slider has no visible text of its own, so without this a screen reader
6
+ * announces a bare number with nothing to say what it measures.
7
+ */
8
+ label: string;
9
+ }
10
+ export declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLSpanElement>>;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Slider as ThemedSlider } from '@radix-ui/themes';
3
+ import { forwardRef } from 'react';
4
+ import { cn } from '../../utils/cn.js';
5
+ export const Slider = forwardRef(function Slider({ label, className, ...props }, ref) {
6
+ return (_jsx(ThemedSlider, { ref: ref, size: "2", "aria-label": label, className: cn(className), ...props }));
7
+ });
@@ -0,0 +1 @@
1
+ export * from './Slider.js';
@@ -0,0 +1 @@
1
+ export * from './Slider.js';
@@ -0,0 +1,59 @@
1
+ import { type ReactNode } from 'react';
2
+ export type ToastType = 'success' | 'warning' | 'error' | 'info';
3
+ export interface ToastAction {
4
+ label: string;
5
+ onClick: () => void;
6
+ /**
7
+ * Leaves the toast open after the click. Closing is the default because nearly every
8
+ * toast action ("Undo", "View") is finished the moment it is pressed.
9
+ */
10
+ keepOpen?: boolean;
11
+ }
12
+ export interface ToastOptions {
13
+ title: ReactNode;
14
+ description?: ReactNode;
15
+ /** Defaults to `info`. */
16
+ type?: ToastType;
17
+ /**
18
+ * Milliseconds before it closes on its own, overriding the `<Toaster>` default.
19
+ * `Infinity` keeps it until dismissed — right for an error the user has to read.
20
+ */
21
+ duration?: number;
22
+ action?: ToastAction;
23
+ /** Reusing an id replaces that toast in place rather than stacking a second one. */
24
+ id?: string;
25
+ }
26
+ declare function show(options: ToastOptions): string;
27
+ type Shorthand = (options: Omit<ToastOptions, 'type'>) => string;
28
+ /**
29
+ * The factory. Each call stacks a new toast and returns its id.
30
+ *
31
+ * toast({ title: 'Saved' });
32
+ * toast.error({ title: 'Payment failed', duration: Infinity });
33
+ * toast.success({ title: 'Booking cancelled', action: { label: 'Undo', onClick: restore } });
34
+ * toast.dismiss(id);
35
+ */
36
+ export declare const toast: typeof show & {
37
+ success: Shorthand;
38
+ warning: Shorthand;
39
+ error: Shorthand;
40
+ info: Shorthand;
41
+ /** Closes one toast, or every toast when called with no id. */
42
+ dismiss(id?: string): void;
43
+ };
44
+ export interface ToasterProps {
45
+ /** Milliseconds a toast stays when it was not given its own `duration`. */
46
+ duration?: number;
47
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
48
+ /** The landmark name screen readers announce, e.g. "Notificações ({hotkey})". */
49
+ label?: string;
50
+ }
51
+ /**
52
+ * Renders the stack. Mount once, inside `<Theme>` so the toasts take the app's palette.
53
+ *
54
+ * Built on Radix's toast primitive rather than Themes, which has none: that is what gives the
55
+ * stack its live-region announcements, the F8 hotkey to reach it, pause-on-hover and swipe to
56
+ * dismiss.
57
+ */
58
+ export declare function Toaster({ duration, position, label }: ToasterProps): import("react").JSX.Element;
59
+ export {};
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as RadixToast from '@radix-ui/react-toast';
3
+ import { useSyncExternalStore } from 'react';
4
+ import { useUiStrings } from '../../strings.js';
5
+ import { cn } from '../../utils/cn.js';
6
+ import { Icon } from '../Icon/Icon.js';
7
+ /**
8
+ * A module-level store, so `toast()` can be called from anywhere — an event handler, a
9
+ * mutation's `onError`, a store action — without threading a hook through to it.
10
+ *
11
+ * One store per bundle is the intent: an app renders exactly one `<Toaster>`. Two would show
12
+ * every toast twice, which is a mistake to fix in the app rather than to guard against here.
13
+ */
14
+ let records = [];
15
+ const listeners = new Set();
16
+ let counter = 0;
17
+ function emit(next) {
18
+ records = next;
19
+ for (const listener of listeners)
20
+ listener();
21
+ }
22
+ function subscribe(listener) {
23
+ listeners.add(listener);
24
+ return () => listeners.delete(listener);
25
+ }
26
+ function show(options) {
27
+ const id = options.id ?? `toast-${++counter}`;
28
+ const record = { ...options, id, type: options.type ?? 'info', open: true };
29
+ const exists = records.some((r) => r.id === id);
30
+ emit(exists ? records.map((r) => (r.id === id ? record : r)) : [...records, record]);
31
+ return id;
32
+ }
33
+ /**
34
+ * The factory. Each call stacks a new toast and returns its id.
35
+ *
36
+ * toast({ title: 'Saved' });
37
+ * toast.error({ title: 'Payment failed', duration: Infinity });
38
+ * toast.success({ title: 'Booking cancelled', action: { label: 'Undo', onClick: restore } });
39
+ * toast.dismiss(id);
40
+ */
41
+ export const toast = Object.assign(show, {
42
+ success: ((o) => show({ ...o, type: 'success' })),
43
+ warning: ((o) => show({ ...o, type: 'warning' })),
44
+ error: ((o) => show({ ...o, type: 'error' })),
45
+ info: ((o) => show({ ...o, type: 'info' })),
46
+ /** Closes one toast, or every toast when called with no id. */
47
+ dismiss(id) {
48
+ emit(records.map((r) => (id === undefined || r.id === id ? { ...r, open: false } : r)));
49
+ },
50
+ });
51
+ /** Removed after the close animation, not at close, or the exit would be cut off. */
52
+ function remove(id) {
53
+ emit(records.filter((r) => r.id !== id));
54
+ }
55
+ /**
56
+ * Themes' colour vocabulary, matching `Badge`: `data-accent-color` re-points `--accent-*` at
57
+ * that scale for the subtree, so the stripe and icon need no colour of their own.
58
+ */
59
+ const TYPES = {
60
+ success: { color: 'jade', icon: 'check-circle' },
61
+ warning: { color: 'amber', icon: 'warning' },
62
+ error: { color: 'red', icon: 'error' },
63
+ info: { color: 'blue', icon: 'info' },
64
+ };
65
+ const POSITIONS = {
66
+ 'top-right': 'top-0 right-0',
67
+ 'top-left': 'top-0 left-0',
68
+ 'bottom-right': 'bottom-0 right-0 flex-col-reverse',
69
+ 'bottom-left': 'bottom-0 left-0 flex-col-reverse',
70
+ };
71
+ /**
72
+ * Renders the stack. Mount once, inside `<Theme>` so the toasts take the app's palette.
73
+ *
74
+ * Built on Radix's toast primitive rather than Themes, which has none: that is what gives the
75
+ * stack its live-region announcements, the F8 hotkey to reach it, pause-on-hover and swipe to
76
+ * dismiss.
77
+ */
78
+ export function Toaster({ duration = 5000, position = 'bottom-right', label }) {
79
+ const items = useSyncExternalStore(subscribe, () => records, () => records);
80
+ const strings = useUiStrings();
81
+ const swipeDirection = position.endsWith('left') ? 'left' : 'right';
82
+ return (_jsxs(RadixToast.Provider, { duration: duration, swipeDirection: swipeDirection, label: label, children: [items.map((item) => {
83
+ const { color, icon } = TYPES[item.type];
84
+ return (_jsxs(RadixToast.Root, { open: item.open, duration: item.duration, type: item.type === 'error' ? 'foreground' : 'background', onOpenChange: (open) => {
85
+ if (!open)
86
+ toast.dismiss(item.id);
87
+ }, onAnimationEnd: () => {
88
+ if (!item.open)
89
+ remove(item.id);
90
+ }, "data-accent-color": color, className: "ui-toast panel pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden py-3 pr-3 pl-4", children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 left-0 w-1 bg-[var(--accent-9)]" }), _jsx(Icon, { name: icon, className: "mt-0.5 text-[var(--accent-11)]" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(RadixToast.Title, { className: "text-sm font-medium text-ink", children: item.title }), item.description ? (_jsx(RadixToast.Description, { className: "mt-0.5 text-sm text-ink-muted", children: item.description })) : null] }), item.action ? (_jsx(RadixToast.Action, { altText: item.action.label, onClick: (event) => {
91
+ item.action?.onClick();
92
+ if (item.action?.keepOpen)
93
+ event.preventDefault();
94
+ }, className: cn('shrink-0 rounded-md px-2 py-1 text-sm font-medium', 'text-[var(--accent-11)] hover:bg-[var(--accent-a3)]'), children: item.action.label })) : null, _jsx(RadixToast.Close, { "aria-label": strings.dismiss, className: "shrink-0 rounded-md p-1 text-ink-muted hover:bg-[var(--gray-a3)]", children: _jsx(Icon, { name: "close", className: "size-4" }) })] }, item.id));
95
+ }), _jsx(RadixToast.Viewport, { className: cn('fixed z-50 m-0 flex w-full max-w-sm list-none flex-col gap-2 p-4 outline-none', POSITIONS[position]) })] }));
96
+ }
@@ -0,0 +1 @@
1
+ export * from './Toast.js';
@@ -0,0 +1 @@
1
+ export * from './Toast.js';
@@ -12,7 +12,9 @@ export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
13
  export * from './Select/index.js';
14
14
  export * from './Separator/index.js';
15
+ export * from './Slider/index.js';
15
16
  export * from './Spinner/index.js';
16
17
  export * from './Switch/index.js';
17
18
  export * from './Textarea/index.js';
19
+ export * from './Toast/index.js';
18
20
  export * from './Tooltip/index.js';
@@ -12,7 +12,9 @@ export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
13
  export * from './Select/index.js';
14
14
  export * from './Separator/index.js';
15
+ export * from './Slider/index.js';
15
16
  export * from './Spinner/index.js';
16
17
  export * from './Switch/index.js';
17
18
  export * from './Textarea/index.js';
19
+ export * from './Toast/index.js';
18
20
  export * from './Tooltip/index.js';
package/dist/strings.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface UiStrings {
18
18
  loading: string;
19
19
  /** The placeholder in a Select that was given none. */
20
20
  select: string;
21
+ /** The accessible name of a toast's close button. */
22
+ dismiss: string;
21
23
  }
22
24
  /**
23
25
  * Feeds the atoms their strings, once, near the root.
package/dist/strings.js CHANGED
@@ -3,6 +3,7 @@ import { createContext, useContext } from 'react';
3
3
  const DEFAULTS = {
4
4
  loading: 'Loading',
5
5
  select: 'Choose…',
6
+ dismiss: 'Dismiss',
6
7
  };
7
8
  const StringsContext = createContext(DEFAULTS);
8
9
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Upfront's house component layer: atoms over Radix Themes, the token bridge, and the accent logic.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  "react-dom": "^19.0.0"
34
34
  },
35
35
  "dependencies": {
36
+ "@radix-ui/react-toast": "^1.2.23",
36
37
  "clsx": "^2.1.1",
37
38
  "tailwind-merge": "^3.7.0"
38
39
  },
package/tokens.css CHANGED
@@ -238,3 +238,26 @@
238
238
  was a blend with nothing to reveal. */
239
239
  @apply rounded-2xl bg-sheet shadow-panel ring-1 ring-hairline;
240
240
  }
241
+
242
+ /**
243
+ * Toast motion. Radix exposes state and swipe through data attributes and CSS variables, and
244
+ * `Toaster` removes a record on `animationend` — so the closed state must animate, or a
245
+ * dismissed toast would never leave the store.
246
+ */
247
+ @keyframes ui-toast-in {
248
+ from { opacity: 0; transform: translateY(0.5rem); }
249
+ }
250
+ @keyframes ui-toast-out {
251
+ to { opacity: 0; }
252
+ }
253
+ @keyframes ui-toast-swipe-out {
254
+ to { transform: translateX(calc(var(--radix-toast-swipe-end-x) + 100%)); opacity: 0; }
255
+ }
256
+ .ui-toast[data-state='open'] { animation: ui-toast-in 180ms ease-out; }
257
+ .ui-toast[data-state='closed'] { animation: ui-toast-out 140ms ease-in forwards; }
258
+ .ui-toast[data-swipe='move'] { transform: translateX(var(--radix-toast-swipe-move-x)); }
259
+ .ui-toast[data-swipe='cancel'] { transform: translateX(0); transition: transform 200ms ease-out; }
260
+ .ui-toast[data-swipe='end'] { animation: ui-toast-swipe-out 120ms ease-out forwards; }
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .ui-toast[data-state] { animation-duration: 1ms; }
263
+ }