@paul-portfolio/react 0.1.17 → 0.3.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 ADDED
@@ -0,0 +1,166 @@
1
+ # @paul-portfolio/react
2
+
3
+ React components for the Paul Design System. Thin, accessible components styled
4
+ by [`@paul-portfolio/css`](https://www.npmjs.com/package/@paul-portfolio/css) —
5
+ the components render semantic markup with class names, and the CSS package
6
+ supplies the looks via design tokens.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @paul-portfolio/react @paul-portfolio/css @paul-portfolio/tokens
12
+ ```
13
+
14
+ `@paul-portfolio/css` and `@paul-portfolio/tokens` are peer dependencies, along
15
+ with `react` and `react-dom` (>=18).
16
+
17
+ ## Setup
18
+
19
+ Import the token variables and the component styles once, near the root of your
20
+ app:
21
+
22
+ ```ts
23
+ import "@paul-portfolio/tokens/tokens.css";
24
+ import "@paul-portfolio/css/components.css";
25
+ ```
26
+
27
+ Then use the components:
28
+
29
+ ```tsx
30
+ import { Button } from "@paul-portfolio/react";
31
+
32
+ export function Example() {
33
+ return <Button variant="primary">Save</Button>;
34
+ }
35
+ ```
36
+
37
+ ## Components
38
+
39
+ ### Button
40
+
41
+ Variants (`primary`, `secondary`, `outline`, `ghost`, `danger`), sizes (`xs`,
42
+ `sm`, `md`, `lg`), `loading`, and an `href` form that renders an `<a>`.
43
+
44
+ ```tsx
45
+ <Button variant="outline" size="sm" onClick={save}>Save</Button>
46
+ <Button href="/docs" variant="ghost">Docs</Button>
47
+ ```
48
+
49
+ ### IconButton
50
+
51
+ A square, icon-only button. Requires `aria-label` since there's no visible text.
52
+ Sizes `sm` and `md`.
53
+
54
+ ```tsx
55
+ <IconButton aria-label="Close" onClick={close}>✕</IconButton>
56
+ ```
57
+
58
+ ### Input / Textarea
59
+
60
+ Labelled fields with `error` and `helper` text. `Input` takes a `size`
61
+ (`sm`/`md`); `Textarea` resizes vertically.
62
+
63
+ ```tsx
64
+ <Input label="Email" type="email" error={emailError} />
65
+ <Textarea label="Bio" helper="Max 200 characters" />
66
+ ```
67
+
68
+ ### Switch
69
+
70
+ An on/off toggle. Controlled via `checked` + `onCheckedChange`. Give it an
71
+ `aria-label`.
72
+
73
+ ```tsx
74
+ <Switch checked={on} onCheckedChange={setOn} aria-label="Notifications" />
75
+ ```
76
+
77
+ ### Chip
78
+
79
+ A compact tag, optionally removable and colorable.
80
+
81
+ ```tsx
82
+ <Chip label="Design" onRemove={() => remove("design")} />
83
+ ```
84
+
85
+ ### Modal
86
+
87
+ An accessible dialog with focus trap, Escape-to-close, and a backdrop. Controlled
88
+ via `open` + `onClose`.
89
+
90
+ ```tsx
91
+ <Modal open={open} onClose={close} aria-label="Settings">
92
+ <h2>Settings</h2>
93
+ </Modal>
94
+ ```
95
+
96
+ ### Tooltip / InfoTip
97
+
98
+ `Tooltip` wraps any element and shows text on hover. `InfoTip` is the common
99
+ "small i that explains a label" shortcut, built on `Tooltip`.
100
+
101
+ ```tsx
102
+ <Tooltip content="Copied to clipboard"><IconButton aria-label="Copy">⧉</IconButton></Tooltip>
103
+ <InfoTip content="We never share your email." />
104
+ ```
105
+
106
+ ### Card
107
+
108
+ A surface with optional `Card.Header`, `Card.Body`, and `Card.Footer`.
109
+
110
+ ```tsx
111
+ <Card>
112
+ <Card.Header>Plan</Card.Header>
113
+ <Card.Body>Pro — $12/mo</Card.Body>
114
+ </Card>
115
+ ```
116
+
117
+ ### Badge / Avatar / Skeleton / Spinner
118
+
119
+ - **Badge** — a small status label.
120
+ - **Avatar** — a rounded user image with initials fallback.
121
+ - **Skeleton** — a shimmer placeholder for content that's still loading.
122
+ - **Spinner** — an indeterminate loading spinner (`sm`/`md`/`lg`), announced as a
123
+ status region.
124
+
125
+ ```tsx
126
+ <Badge>New</Badge>
127
+ <Avatar name="Ada Lovelace" src={url} />
128
+ <Skeleton width="12rem" />
129
+ <Spinner label="Loading results" />
130
+ ```
131
+
132
+ ### Divider
133
+
134
+ A thin separator rule, `horizontal` (default) or `vertical`.
135
+
136
+ ```tsx
137
+ <Divider />
138
+ <Divider orientation="vertical" />
139
+ ```
140
+
141
+ ### VisuallyHidden
142
+
143
+ Renders content that's available to screen readers but hidden visually.
144
+
145
+ ```tsx
146
+ <VisuallyHidden>Loading</VisuallyHidden>
147
+ ```
148
+
149
+ ### cx
150
+
151
+ A tiny classname joiner used internally, exported for convenience.
152
+
153
+ ```ts
154
+ cx("card", isActive && "card--active"); // "card card--active"
155
+ ```
156
+
157
+ ## Accessibility
158
+
159
+ Every component ships with an axe test in the package's suite. Components that
160
+ have no visible text (IconButton, Switch, Spinner) require or default an
161
+ accessible name, and interactive components expose the right roles and ARIA
162
+ state.
163
+
164
+ ## License
165
+
166
+ MIT © Paul Sumido
package/dist/Avatar.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from './cx';
3
- export function Avatar({ src, alt, size, fallback }) {
3
+ export function Avatar({ src, alt, size = 'md', fallback }) {
4
4
  return (_jsx("div", { className: cx('avatar', size && `avatar--${size}`), children: src ? (_jsx("img", { src: src, alt: alt })) : (_jsx("span", { className: "avatar--fallback", children: fallback })) }));
5
5
  }
package/dist/Chip.d.ts CHANGED
@@ -1,10 +1,17 @@
1
- import type { HTMLAttributes } from 'react';
2
- type ChipProps = HTMLAttributes<HTMLSpanElement> & {
1
+ import type { HTMLAttributes, MouseEvent } from 'react';
2
+ type ChipProps = Omit<HTMLAttributes<HTMLSpanElement>, 'onClick'> & {
3
3
  label: string;
4
+ /** Background color (any CSS color). Text flips to white when set. */
5
+ color?: string;
4
6
  size?: 'sm' | 'md';
7
+ /** Stretch to fill the container (e.g. a grid cell). */
8
+ fullWidth?: boolean;
5
9
  clickable?: boolean;
6
10
  removable?: boolean;
11
+ /** When set, the label becomes a real button. */
12
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
7
13
  onRemove?: () => void;
14
+ title?: string;
8
15
  };
9
- export declare function Chip({ label, size, clickable, removable, onClick, onRemove, className, ...props }: ChipProps): import("react").JSX.Element;
16
+ export declare function Chip({ label, color, size, fullWidth, clickable, removable, onClick, onRemove, title, className, ...props }: ChipProps): import("react").JSX.Element;
10
17
  export {};
package/dist/Chip.js CHANGED
@@ -1,8 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from './cx';
3
- export function Chip({ label, size, clickable, removable, onClick, onRemove, className, ...props }) {
4
- return (_jsxs("span", { className: cx('chip', size && size !== 'md' && `chip--${size}`, clickable && 'chip--clickable', removable && 'chip--removable', className), onClick: onClick, ...props, children: [label, removable && (_jsx("button", { type: "button", className: "chip__remove", "aria-label": "Remove", onClick: (e) => {
5
- e.stopPropagation();
6
- onRemove?.();
7
- } }))] }));
3
+ export function Chip({ label, color, size, fullWidth, clickable, removable, onClick, onRemove, title, className, ...props }) {
4
+ const showRemove = removable || !!onRemove;
5
+ const interactive = clickable || !!onClick;
6
+ const classes = cx('chip', size && size !== 'md' && `chip--${size}`, interactive && 'chip--clickable', showRemove && 'chip--removable', fullWidth && 'chip--full-width', className);
7
+ const style = color
8
+ ? { backgroundColor: color, color: '#fff' }
9
+ : undefined;
10
+ const remove = showRemove ? (_jsx("button", { type: "button", className: "chip__remove", "aria-label": `Remove ${label}`, onClick: (e) => {
11
+ e.stopPropagation();
12
+ onRemove?.();
13
+ }, children: _jsx("span", { "aria-hidden": "true", children: "\u00D7" }) })) : null;
14
+ // A clickable chip puts the label in its own button so it's keyboard
15
+ // reachable, with the remove button as a sibling (no button-in-button).
16
+ if (onClick) {
17
+ return (_jsxs("span", { className: classes, style: style, title: title, ...props, children: [_jsx("button", { type: "button", className: "chip__label", onClick: onClick, children: label }), remove] }));
18
+ }
19
+ return (_jsxs("span", { className: classes, style: style, title: title, ...props, children: [label, remove] }));
8
20
  }
@@ -0,0 +1,10 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ type DividerProps = HTMLAttributes<HTMLHRElement> & {
3
+ orientation?: 'horizontal' | 'vertical';
4
+ };
5
+ /**
6
+ * A thin rule that separates content. Renders an <hr> (implicit
7
+ * role="separator"); pass orientation="vertical" for use inside a flex row.
8
+ */
9
+ export declare function Divider({ orientation, className, ...props }: DividerProps): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ /**
4
+ * A thin rule that separates content. Renders an <hr> (implicit
5
+ * role="separator"); pass orientation="vertical" for use inside a flex row.
6
+ */
7
+ export function Divider({ orientation = 'horizontal', className, ...props }) {
8
+ return (_jsx("hr", { className: cx('divider', orientation === 'vertical' && 'divider--vertical', className), "aria-orientation": orientation, ...props }));
9
+ }
@@ -0,0 +1,12 @@
1
+ import { type ButtonHTMLAttributes, type ReactNode } from 'react';
2
+ /**
3
+ * A square, icon-only button. Always needs an aria-label because there's no
4
+ * visible text to name it.
5
+ */
6
+ export declare const IconButton: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
7
+ /** Required: describes the action for screen readers, since the button
8
+ * holds only an icon. */
9
+ 'aria-label': string;
10
+ size?: 'sm' | 'md';
11
+ children: ReactNode;
12
+ } & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cx } from './cx';
4
+ /**
5
+ * A square, icon-only button. Always needs an aria-label because there's no
6
+ * visible text to name it.
7
+ */
8
+ export const IconButton = forwardRef(function IconButton({ size, className, children, type, ...props }, ref) {
9
+ return (_jsx("button", { ref: ref, type: type ?? 'button', className: cx('icon-btn', size && size !== 'md' && `icon-btn--${size}`, className), ...props, children: children }));
10
+ });
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react';
2
+ type InfoTipProps = {
3
+ /** The explanation shown in the popover. Text or rich nodes. */
4
+ content: ReactNode;
5
+ side?: 'top' | 'bottom' | 'left' | 'right';
6
+ /** Accessible name for the trigger. Defaults to "More information". */
7
+ label?: string;
8
+ /** Max width of the popover in px. */
9
+ maxWidth?: number;
10
+ /** Delay before showing, in ms. */
11
+ delay?: number;
12
+ };
13
+ /**
14
+ * A small "i" glyph that reveals a popover on hover or focus. Built on Tooltip,
15
+ * so it renders at a fixed position (never clipped) and accepts rich content —
16
+ * the common "explain this label" case, with room for a few lines of detail.
17
+ */
18
+ export declare function InfoTip({ content, side, label, maxWidth, delay, }: InfoTipProps): import("react").JSX.Element;
19
+ export {};
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Tooltip } from './Tooltip';
3
+ /**
4
+ * A small "i" glyph that reveals a popover on hover or focus. Built on Tooltip,
5
+ * so it renders at a fixed position (never clipped) and accepts rich content —
6
+ * the common "explain this label" case, with room for a few lines of detail.
7
+ */
8
+ export function InfoTip({ content, side = 'top', label = 'More information', maxWidth, delay, }) {
9
+ return (_jsx(Tooltip, { content: content, side: side, maxWidth: maxWidth, delay: delay, children: _jsx("span", { className: "info-tip", role: "img", "aria-label": label, tabIndex: 0, children: "i" }) }));
10
+ }
package/dist/Modal.d.ts CHANGED
@@ -3,6 +3,13 @@ type ModalProps = {
3
3
  open: boolean;
4
4
  onClose: () => void;
5
5
  title?: string;
6
+ /** Accessible name when there's no title to point at. */
7
+ 'aria-label'?: string;
8
+ /** Id of an element that labels the dialog (wins over title). */
9
+ 'aria-labelledby'?: string;
10
+ /** Id of an element that describes the dialog. */
11
+ 'aria-describedby'?: string;
12
+ className?: string;
6
13
  children: ReactNode;
7
14
  };
8
15
  declare function Header({ children }: {
@@ -14,7 +21,7 @@ declare function Body({ children }: {
14
21
  declare function Footer({ children }: {
15
22
  children: ReactNode;
16
23
  }): import("react").JSX.Element;
17
- export declare function Modal({ open, onClose, title, children }: ModalProps): import("react").ReactPortal | null;
24
+ export declare function Modal({ open, onClose, title, className, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, }: ModalProps): import("react").ReactPortal | null;
18
25
  export declare namespace Modal {
19
26
  export { Header };
20
27
  export { Body };
package/dist/Modal.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useId } from 'react';
2
+ import { useEffect, useId, useRef } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
+ import { cx } from './cx';
4
5
  function Header({ children }) {
5
6
  return _jsx("div", { className: "modal__header", children: children });
6
7
  }
@@ -10,21 +11,51 @@ function Body({ children }) {
10
11
  function Footer({ children }) {
11
12
  return _jsx("div", { className: "modal__footer", children: children });
12
13
  }
13
- export function Modal({ open, onClose, title, children }) {
14
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
15
+ export function Modal({ open, onClose, title, className, children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, }) {
14
16
  const titleId = useId();
17
+ const dialogRef = useRef(null);
18
+ // Move focus into the dialog on open and restore it on close, and trap Tab
19
+ // so keyboard focus can't wander behind the modal.
15
20
  useEffect(() => {
16
21
  if (!open)
17
22
  return;
23
+ const previouslyFocused = document.activeElement;
24
+ const dialog = dialogRef.current;
25
+ dialog?.focus();
18
26
  function handleKey(e) {
19
- if (e.key === 'Escape')
27
+ if (e.key === 'Escape') {
20
28
  onClose();
29
+ return;
30
+ }
31
+ if (e.key !== 'Tab' || !dialog)
32
+ return;
33
+ const focusables = Array.from(dialog.querySelectorAll(FOCUSABLE));
34
+ if (focusables.length === 0) {
35
+ e.preventDefault();
36
+ return;
37
+ }
38
+ const first = focusables[0];
39
+ const last = focusables[focusables.length - 1];
40
+ if (e.shiftKey && document.activeElement === first) {
41
+ e.preventDefault();
42
+ last.focus();
43
+ }
44
+ else if (!e.shiftKey && document.activeElement === last) {
45
+ e.preventDefault();
46
+ first.focus();
47
+ }
21
48
  }
22
49
  document.addEventListener('keydown', handleKey);
23
- return () => document.removeEventListener('keydown', handleKey);
50
+ return () => {
51
+ document.removeEventListener('keydown', handleKey);
52
+ previouslyFocused?.focus?.();
53
+ };
24
54
  }, [open, onClose]);
25
55
  if (!open)
26
56
  return null;
27
- return createPortal(_jsx("div", { className: "modal__backdrop", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": title ? titleId : undefined, className: "modal", onClick: (e) => e.stopPropagation(), children: [title && (_jsx("div", { id: titleId, className: "modal__title", children: title })), children] }) }), document.body);
57
+ const labelledby = ariaLabelledby ?? (title ? titleId : undefined);
58
+ return createPortal(_jsx("div", { className: "modal__backdrop", onClick: onClose, children: _jsxs("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": !labelledby ? ariaLabel : undefined, "aria-labelledby": labelledby, "aria-describedby": ariaDescribedby, tabIndex: -1, className: cx('modal__content', className), onClick: (e) => e.stopPropagation(), children: [title && (_jsx("div", { id: titleId, className: "modal__header", children: title })), children] }) }), document.body);
28
59
  }
29
60
  Modal.Header = Header;
30
61
  Modal.Body = Body;
package/dist/Skeleton.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from './cx';
3
3
  export function Skeleton({ variant, width, height }) {
4
- const style = variant === 'rect'
4
+ const style = width || height
5
5
  ? {
6
6
  '--skeleton-w': width,
7
7
  '--skeleton-h': height,
@@ -0,0 +1,12 @@
1
+ import { type HTMLAttributes } from 'react';
2
+ type SpinnerProps = HTMLAttributes<HTMLSpanElement> & {
3
+ size?: 'sm' | 'md' | 'lg';
4
+ /** Accessible name announced to screen readers. Defaults to "Loading". */
5
+ label?: string;
6
+ };
7
+ /**
8
+ * An indeterminate loading spinner. Renders as a live status region so
9
+ * assistive tech announces that something is loading.
10
+ */
11
+ export declare function Spinner({ size, label, className, ...props }: SpinnerProps): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ /**
4
+ * An indeterminate loading spinner. Renders as a live status region so
5
+ * assistive tech announces that something is loading.
6
+ */
7
+ export function Spinner({ size, label = 'Loading', className, ...props }) {
8
+ return (_jsx("span", { role: "status", "aria-label": label, className: cx('spinner', size && size !== 'md' && `spinner--${size}`, className), ...props }));
9
+ }
@@ -0,0 +1,13 @@
1
+ import { type ButtonHTMLAttributes } from 'react';
2
+ /**
3
+ * An on/off toggle. Controlled via `checked` + `onCheckedChange`. Exposes
4
+ * role="switch" with aria-checked so assistive tech reads its state. Give it
5
+ * an aria-label (or aria-labelledby) since it has no text of its own.
6
+ */
7
+ export declare const Switch: import("react").ForwardRefExoticComponent<Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> & {
8
+ /** Whether the switch is on. Controlled. */
9
+ checked: boolean;
10
+ /** Called with the next value when toggled. */
11
+ onCheckedChange?: (checked: boolean) => void;
12
+ disabled?: boolean;
13
+ } & import("react").RefAttributes<HTMLButtonElement>>;
package/dist/Switch.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from 'react';
3
+ import { cx } from './cx';
4
+ /**
5
+ * An on/off toggle. Controlled via `checked` + `onCheckedChange`. Exposes
6
+ * role="switch" with aria-checked so assistive tech reads its state. Give it
7
+ * an aria-label (or aria-labelledby) since it has no text of its own.
8
+ */
9
+ export const Switch = forwardRef(function Switch({ checked, onCheckedChange, disabled, className, ...props }, ref) {
10
+ return (_jsx("button", { ref: ref, type: "button", role: "switch", "aria-checked": checked, disabled: disabled, onClick: () => onCheckedChange?.(!checked), className: cx('switch', checked && 'switch--on', className), ...props, children: _jsx("span", { className: "switch__thumb" }) }));
11
+ });
@@ -0,0 +1,16 @@
1
+ import { type TextareaHTMLAttributes } from 'react';
2
+ /**
3
+ * Multi-line text field. Mirrors Input's label/error/helper API, and adds an
4
+ * optional hidden label, a required marker, and a live character counter when
5
+ * paired with maxLength.
6
+ */
7
+ export declare const Textarea: import("react").ForwardRefExoticComponent<TextareaHTMLAttributes<HTMLTextAreaElement> & {
8
+ label?: string;
9
+ error?: string;
10
+ helper?: string;
11
+ /** Visually hide the label while keeping it available to screen readers. */
12
+ hideLabel?: boolean;
13
+ /** Show a live "used / max" character count. Needs maxLength to be set. */
14
+ showCount?: boolean;
15
+ disabled?: boolean;
16
+ } & import("react").RefAttributes<HTMLTextAreaElement>>;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId, useState, } from 'react';
3
+ import { cx } from './cx';
4
+ /**
5
+ * Multi-line text field. Mirrors Input's label/error/helper API, and adds an
6
+ * optional hidden label, a required marker, and a live character counter when
7
+ * paired with maxLength.
8
+ */
9
+ export const Textarea = forwardRef(function Textarea({ label, error, helper, hideLabel = false, showCount = false, required, disabled, maxLength, value, defaultValue, onChange, className, ...props }, ref) {
10
+ const id = useId();
11
+ const helperId = `${id}-helper`;
12
+ const countId = `${id}-count`;
13
+ const helperText = error || helper;
14
+ // Count is derived from a controlled value, or tracked locally otherwise.
15
+ const controlled = value !== undefined;
16
+ const [localCount, setLocalCount] = useState(() => String(defaultValue ?? '').length);
17
+ const count = controlled ? String(value ?? '').length : localCount;
18
+ const withCount = showCount && maxLength != null;
19
+ const describedBy = [helperText ? helperId : null, withCount ? countId : null]
20
+ .filter(Boolean)
21
+ .join(' ') || undefined;
22
+ return (_jsxs("div", { className: "input__wrapper", children: [label && (_jsxs("label", { className: hideLabel ? 'sr-only' : 'input__label', htmlFor: id, children: [label, required && (_jsx("span", { "aria-hidden": "true", children: " *" }))] })), _jsx("textarea", { ref: ref, id: id, className: cx('textarea', error && 'textarea--error', className), disabled: disabled, required: required, maxLength: maxLength, value: value, defaultValue: defaultValue, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy, onChange: (e) => {
23
+ if (!controlled)
24
+ setLocalCount(e.target.value.length);
25
+ onChange?.(e);
26
+ }, ...props }), withCount && (_jsxs("span", { id: countId, className: "textarea__count", "aria-live": "polite", children: [count, " / ", maxLength] })), helperText && (_jsx("span", { id: helperId, className: cx('input__helper', error && 'input__helper--error'), children: helperText }))] }));
27
+ });
package/dist/Tooltip.d.ts CHANGED
@@ -1,8 +1,19 @@
1
1
  import { type ReactNode } from 'react';
2
+ type TooltipSide = 'top' | 'bottom' | 'left' | 'right';
2
3
  type TooltipProps = {
3
- content: string;
4
- side?: 'top' | 'bottom' | 'left' | 'right';
4
+ /** Content shown in the floating label. Text or rich nodes. */
5
+ content: ReactNode;
6
+ side?: TooltipSide;
7
+ /** Delay before showing, in ms. Avoids flashing on a quick mouse pass. */
8
+ delay?: number;
9
+ /** Max width of the bubble in px. */
10
+ maxWidth?: number;
5
11
  children: ReactNode;
6
12
  };
7
- export declare function Tooltip({ content, side, children }: TooltipProps): import("react").JSX.Element;
13
+ /**
14
+ * A tooltip that renders at a fixed screen position, so it's never clipped by
15
+ * an overflow:hidden ancestor (grids, cards, chips) and needs no portal. Shows
16
+ * on hover and focus after `delay` ms; Escape dismisses it.
17
+ */
18
+ export declare function Tooltip({ content, side, delay, maxWidth, children }: TooltipProps): import("react").JSX.Element;
8
19
  export {};
package/dist/Tooltip.js CHANGED
@@ -1,8 +1,63 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useId } from 'react';
2
+ import { useState, useRef, useCallback, useId, } from 'react';
3
3
  import { cx } from './cx';
4
- export function Tooltip({ content, side = 'top', children }) {
5
- const [visible, setVisible] = useState(false);
4
+ const GAP = 8;
5
+ /**
6
+ * A tooltip that renders at a fixed screen position, so it's never clipped by
7
+ * an overflow:hidden ancestor (grids, cards, chips) and needs no portal. Shows
8
+ * on hover and focus after `delay` ms; Escape dismisses it.
9
+ */
10
+ export function Tooltip({ content, side = 'top', delay = 500, maxWidth, children }) {
6
11
  const id = useId();
7
- return (_jsxs("span", { onMouseEnter: () => setVisible(true), onMouseLeave: () => setVisible(false), "aria-describedby": id, style: { position: 'relative', display: 'inline-block' }, children: [children, _jsx("span", { id: id, role: "tooltip", className: cx('tooltip', `tooltip--${side}`, visible && 'tooltip--visible'), children: content })] }));
12
+ const [visible, setVisible] = useState(false);
13
+ const [rect, setRect] = useState(null);
14
+ const timer = useRef(null);
15
+ const show = useCallback((el) => {
16
+ setRect(el.getBoundingClientRect());
17
+ if (timer.current)
18
+ clearTimeout(timer.current);
19
+ timer.current = setTimeout(() => setVisible(true), delay);
20
+ }, [delay]);
21
+ const hide = useCallback(() => {
22
+ if (timer.current)
23
+ clearTimeout(timer.current);
24
+ setVisible(false);
25
+ }, []);
26
+ const style = rect
27
+ ? { position: 'fixed', ...place(rect, side), maxWidth }
28
+ : undefined;
29
+ return (_jsxs("span", { className: "tooltip__anchor", style: { display: 'inline-flex' }, onMouseEnter: (e) => show(e.currentTarget), onMouseLeave: hide, onFocus: (e) => show(e.currentTarget), onBlur: hide, onKeyDown: (e) => {
30
+ if (e.key === 'Escape' && visible)
31
+ hide();
32
+ }, "aria-describedby": visible ? id : undefined, children: [children, visible && rect && (_jsx("span", { id: id, role: "tooltip", className: cx('tooltip', `tooltip--${side}`, 'tooltip--visible'), style: style, children: content }))] }));
33
+ }
34
+ /** Screen coordinates + transform to anchor the bubble on a side of the rect. */
35
+ function place(rect, side) {
36
+ switch (side) {
37
+ case 'bottom':
38
+ return {
39
+ left: rect.left + rect.width / 2,
40
+ top: rect.bottom + GAP,
41
+ transform: 'translateX(-50%)',
42
+ };
43
+ case 'left':
44
+ return {
45
+ left: rect.left - GAP,
46
+ top: rect.top + rect.height / 2,
47
+ transform: 'translate(-100%, -50%)',
48
+ };
49
+ case 'right':
50
+ return {
51
+ left: rect.right + GAP,
52
+ top: rect.top + rect.height / 2,
53
+ transform: 'translateY(-50%)',
54
+ };
55
+ case 'top':
56
+ default:
57
+ return {
58
+ left: rect.left + rect.width / 2,
59
+ top: rect.top - GAP,
60
+ transform: 'translate(-50%, -100%)',
61
+ };
62
+ }
8
63
  }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  export { Button } from './Button';
2
+ export { IconButton } from './IconButton';
2
3
  export { Card } from './Card';
3
4
  export { Chip } from './Chip';
4
5
  export { Input } from './Input';
6
+ export { Textarea } from './Textarea';
7
+ export { Switch } from './Switch';
5
8
  export { Modal } from './Modal';
6
9
  export { Tooltip } from './Tooltip';
10
+ export { InfoTip } from './InfoTip';
7
11
  export { Avatar } from './Avatar';
8
12
  export { Badge } from './Badge';
9
13
  export { Skeleton } from './Skeleton';
14
+ export { Spinner } from './Spinner';
15
+ export { Divider } from './Divider';
10
16
  export { VisuallyHidden } from './VisuallyHidden';
11
17
  export { cx } from './cx';
package/dist/index.js CHANGED
@@ -1,11 +1,17 @@
1
1
  export { Button } from './Button';
2
+ export { IconButton } from './IconButton';
2
3
  export { Card } from './Card';
3
4
  export { Chip } from './Chip';
4
5
  export { Input } from './Input';
6
+ export { Textarea } from './Textarea';
7
+ export { Switch } from './Switch';
5
8
  export { Modal } from './Modal';
6
9
  export { Tooltip } from './Tooltip';
10
+ export { InfoTip } from './InfoTip';
7
11
  export { Avatar } from './Avatar';
8
12
  export { Badge } from './Badge';
9
13
  export { Skeleton } from './Skeleton';
14
+ export { Spinner } from './Spinner';
15
+ export { Divider } from './Divider';
10
16
  export { VisuallyHidden } from './VisuallyHidden';
11
17
  export { cx } from './cx';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paul-portfolio/react",
3
- "version": "0.1.17",
3
+ "version": "0.3.0",
4
4
  "description": "React components for the Paul Design System",
5
5
  "license": "MIT",
6
6
  "repository": {