@imdz/react 0.1.0 → 0.2.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.
@@ -1 +1 @@
1
- export declare const css = "@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/inter-latin-400-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/inter-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz JetBrains Mono\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/jetbrains-mono-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}";
1
+ export declare const css = "@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/inter-latin-400-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/inter-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz JetBrains Mono\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/jetbrains-mono-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}";
@@ -1 +1 @@
1
- export const css = "@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/inter-latin-400-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/inter-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz JetBrains Mono\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0.1.0/dist/fonts/jetbrains-mono-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}";
1
+ export const css = "@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/inter-latin-400-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz Inter\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/inter-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n@font-face {\n font-family: \"imdz JetBrains Mono\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://cdn.jsdelivr.net/npm/@imdz/react@0/dist/fonts/jetbrains-mono-latin-600-normal.woff2) format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}";
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Design tokens: the source of the default values.
3
+ *
4
+ * Components don't depend on this file. When @imdz/react is built
5
+ * (its scripts/build.mjs), every var(--imdz-x) in a component's CSS
6
+ * becomes var(--imdz-x, value) with the value from here, so each
7
+ * component works on its own and anyone can override any token in :root.
8
+ *
9
+ * The palette is monochrome: black, white and grays. Color only shows up in
10
+ * states (success, warning, error).
11
+ *
12
+ * light-dark(light, dark): the store website and the Web Components load
13
+ * this file as is and follow the page's color-scheme. In the published
14
+ * package, the build keeps only the light value and moves the dark one to
15
+ * dist/dark.css.
16
+ */
17
+ :where(:root) {
18
+ /* The @font-face rules are in fonts.css; the components put them on the
19
+ page themselves. */
20
+ --imdz-font: "imdz Inter", system-ui, -apple-system, "Segoe UI", Roboto,
21
+ sans-serif;
22
+ --imdz-font-mono: "imdz JetBrains Mono", ui-monospace, "SF Mono", Menlo,
23
+ Consolas, monospace;
24
+ --imdz-radius: 0px;
25
+ --imdz-tracking: 0.08em;
26
+
27
+ --imdz-bg: light-dark(#ffffff, #000000);
28
+ --imdz-surface: light-dark(#f2f2f2, #0d0d0d);
29
+ --imdz-border: light-dark(#c4c4c4, #2e2e2e);
30
+ --imdz-text: light-dark(#000000, #ffffff);
31
+ --imdz-muted: light-dark(#5e5e5e, #9a9a9a);
32
+
33
+ --imdz-primary: var(--imdz-text);
34
+ --imdz-on-primary: var(--imdz-bg);
35
+ --imdz-success: light-dark(#0f7a3d, #5dffa0);
36
+ --imdz-warning: light-dark(#8f5a00, #ffd166);
37
+ --imdz-danger: light-dark(#c4001a, #ff5c6c);
38
+ --imdz-on-danger: light-dark(#ffffff, #000000);
39
+
40
+ --imdz-focus: var(--imdz-text);
41
+ }
42
+
43
+ :where([data-imdz-theme="light"]) {
44
+ color-scheme: light;
45
+ }
46
+
47
+ :where([data-imdz-theme="dark"]) {
48
+ color-scheme: dark;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdz/react",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Accessible React components with plain CSS. npm i @imdz/react and import them: no Tailwind, no config, works with any React framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,10 +17,12 @@
17
17
  "./dark.css": "./dist/dark.css",
18
18
  "./styles.css": "./dist/styles.css",
19
19
  "./fonts.css": "./dist/fonts.css",
20
- "./package.json": "./package.json"
20
+ "./package.json": "./package.json",
21
+ "./tokens.css": "./dist/tokens.css"
21
22
  },
22
23
  "files": [
23
24
  "dist",
25
+ "src",
24
26
  "OFL.txt",
25
27
  "LICENSE"
26
28
  ],
@@ -30,6 +32,7 @@
30
32
  "homepage": "https://imdz.dev",
31
33
  "scripts": {
32
34
  "build": "node scripts/build.mjs",
35
+ "dev": "npm run build && node scripts/dev.mjs",
33
36
  "prepack": "node scripts/build.mjs"
34
37
  },
35
38
  "keywords": [
@@ -42,8 +45,12 @@
42
45
  "peerDependencies": {
43
46
  "react": ">=18"
44
47
  },
48
+ "dependencies": {
49
+ "@imdz/styles": "^0.1.0"
50
+ },
45
51
  "devDependencies": {
46
- "@fontsource/inter": "^5.3.0",
47
- "@fontsource/jetbrains-mono": "^5.3.0"
52
+ "@types/react": "^19",
53
+ "react": "19.3.0",
54
+ "typescript": "^5"
48
55
  }
49
56
  }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/accordion.module.css";
4
+
5
+ export type AccordionItem = {
6
+ heading: string;
7
+ content: ReactNode;
8
+ defaultOpen?: boolean;
9
+ };
10
+
11
+ export type AccordionProps = {
12
+ items: AccordionItem[];
13
+ /** If set, only one section can be open at a time. */
14
+ name?: string;
15
+ className?: string;
16
+ };
17
+
18
+ /** Collapsible sections built on the native details element. */
19
+ export function Accordion({ items, name, className }: AccordionProps) {
20
+ return (
21
+ <div className={cx(styles.accordion, className)}>
22
+ {items.map((item) => (
23
+ <details
24
+ key={item.heading}
25
+ name={name}
26
+ open={item.defaultOpen}
27
+ className={styles.item}
28
+ >
29
+ <summary className={styles.summary}>{item.heading}</summary>
30
+ <div className={styles.content}>{item.content}</div>
31
+ </details>
32
+ ))}
33
+ </div>
34
+ );
35
+ }
package/src/alert.tsx ADDED
@@ -0,0 +1,33 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/alert.module.css";
4
+
5
+ export type AlertVariant = "info" | "success" | "warning" | "danger";
6
+
7
+ export type AlertProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
8
+ variant?: AlertVariant;
9
+ heading?: ReactNode;
10
+ };
11
+
12
+ export function Alert({
13
+ variant = "info",
14
+ heading,
15
+ className,
16
+ children,
17
+ ...props
18
+ }: AlertProps) {
19
+ return (
20
+ <div
21
+ role={variant === "danger" ? "alert" : "status"}
22
+ className={cx(
23
+ styles.alert,
24
+ variant !== "info" && styles[variant],
25
+ className,
26
+ )}
27
+ {...props}
28
+ >
29
+ {heading && <p className={styles.title}>{heading}</p>}
30
+ <div>{children}</div>
31
+ </div>
32
+ );
33
+ }
package/src/avatar.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import { cx } from "./cx";
2
+ import styles from "@imdz/styles/avatar.module.css";
3
+
4
+ export type AvatarProps = {
5
+ /** The person's name: gives the initials and the alt text. */
6
+ name: string;
7
+ src?: string;
8
+ size?: "md" | "lg";
9
+ className?: string;
10
+ };
11
+
12
+ export function Avatar({ name, src, size = "md", className }: AvatarProps) {
13
+ const classes = cx(styles.avatar, size === "lg" && styles.lg, className);
14
+
15
+ if (src) {
16
+ return (
17
+ <span className={classes}>
18
+ {/* Image from any source: optimizing it is up to the project. */}
19
+ {/* eslint-disable-next-line @next/next/no-img-element */}
20
+ <img src={src} alt={name} />
21
+ </span>
22
+ );
23
+ }
24
+
25
+ const initials = name
26
+ .split(/\s+/)
27
+ .filter(Boolean)
28
+ .slice(0, 2)
29
+ .map((word) => word[0])
30
+ .join("");
31
+
32
+ return (
33
+ <span className={classes} role="img" aria-label={name}>
34
+ {initials}
35
+ </span>
36
+ );
37
+ }
package/src/badge.tsx ADDED
@@ -0,0 +1,31 @@
1
+ import type { HTMLAttributes } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/badge.module.css";
4
+
5
+ export type BadgeVariant =
6
+ | "neutral"
7
+ | "primary"
8
+ | "success"
9
+ | "warning"
10
+ | "danger";
11
+
12
+ export type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
13
+ variant?: BadgeVariant;
14
+ };
15
+
16
+ export function Badge({
17
+ variant = "neutral",
18
+ className,
19
+ ...props
20
+ }: BadgeProps) {
21
+ return (
22
+ <span
23
+ className={cx(
24
+ styles.badge,
25
+ variant !== "neutral" && styles[variant],
26
+ className,
27
+ )}
28
+ {...props}
29
+ />
30
+ );
31
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,32 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/button.module.css";
4
+
5
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
6
+ export type ButtonSize = "sm" | "md" | "lg";
7
+
8
+ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
9
+ variant?: ButtonVariant;
10
+ size?: ButtonSize;
11
+ };
12
+
13
+ export function Button({
14
+ variant = "primary",
15
+ size = "md",
16
+ type = "button",
17
+ className,
18
+ ...props
19
+ }: ButtonProps) {
20
+ return (
21
+ <button
22
+ type={type}
23
+ className={cx(
24
+ styles.button,
25
+ variant !== "primary" && styles[variant],
26
+ size !== "md" && styles[size],
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+ }
package/src/card.tsx ADDED
@@ -0,0 +1,24 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/card.module.css";
4
+
5
+ export type CardProps = Omit<HTMLAttributes<HTMLDivElement>, "title"> & {
6
+ heading?: ReactNode;
7
+ footer?: ReactNode;
8
+ };
9
+
10
+ export function Card({
11
+ heading,
12
+ footer,
13
+ className,
14
+ children,
15
+ ...props
16
+ }: CardProps) {
17
+ return (
18
+ <div className={cx(styles.card, className)} {...props}>
19
+ {heading && <h3 className={styles.title}>{heading}</h3>}
20
+ <div className={styles.body}>{children}</div>
21
+ {footer && <div className={styles.footer}>{footer}</div>}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,18 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/checkbox.module.css";
4
+
5
+ export type CheckboxProps = Omit<
6
+ InputHTMLAttributes<HTMLInputElement>,
7
+ "type"
8
+ >;
9
+
10
+ /** The checkbox label goes as children. */
11
+ export function Checkbox({ className, children, ...props }: CheckboxProps) {
12
+ return (
13
+ <label className={cx(styles.checkbox, className)}>
14
+ <input type="checkbox" className={styles.input} {...props} />
15
+ <span>{children}</span>
16
+ </label>
17
+ );
18
+ }
package/src/cx.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function cx(...classes: (string | false | null | undefined)[]) {
2
+ return classes.filter(Boolean).join(" ");
3
+ }
package/src/dialog.tsx ADDED
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, type ReactNode } from "react";
4
+ import { cx } from "./cx";
5
+ import styles from "@imdz/styles/dialog.module.css";
6
+
7
+ export type DialogProps = {
8
+ open: boolean;
9
+ /** Called on close: with Escape, with the close button or when open becomes false. */
10
+ onClose: () => void;
11
+ heading: ReactNode;
12
+ footer?: ReactNode;
13
+ closeLabel?: string;
14
+ className?: string;
15
+ children?: ReactNode;
16
+ };
17
+
18
+ export function Dialog({
19
+ open,
20
+ onClose,
21
+ heading,
22
+ footer,
23
+ closeLabel = "Close",
24
+ className,
25
+ children,
26
+ }: DialogProps) {
27
+ const ref = useRef<HTMLDialogElement>(null);
28
+ const titleId = useId();
29
+
30
+ useEffect(() => {
31
+ const element = ref.current;
32
+ if (!element) return;
33
+ if (open && !element.open) element.showModal();
34
+ if (!open && element.open) element.close();
35
+ }, [open]);
36
+
37
+ return (
38
+ <dialog
39
+ ref={ref}
40
+ className={cx(styles.dialog, className)}
41
+ aria-labelledby={titleId}
42
+ onClose={onClose}
43
+ >
44
+ <header className={styles.header}>
45
+ <h2 id={titleId} className={styles.title}>
46
+ {heading}
47
+ </h2>
48
+ <button
49
+ type="button"
50
+ className={styles.close}
51
+ aria-label={closeLabel}
52
+ onClick={onClose}
53
+ >
54
+ &times;
55
+ </button>
56
+ </header>
57
+ <div className={styles.body}>{children}</div>
58
+ {footer && <footer className={styles.footer}>{footer}</footer>}
59
+ </dialog>
60
+ );
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export {
2
+ Accordion,
3
+ type AccordionItem,
4
+ type AccordionProps,
5
+ } from "./accordion";
6
+ export { Alert, type AlertProps, type AlertVariant } from "./alert";
7
+ export { Avatar, type AvatarProps } from "./avatar";
8
+ export { Badge, type BadgeProps, type BadgeVariant } from "./badge";
9
+ export {
10
+ Button,
11
+ type ButtonProps,
12
+ type ButtonSize,
13
+ type ButtonVariant,
14
+ } from "./button";
15
+ export { Card, type CardProps } from "./card";
16
+ export { Checkbox, type CheckboxProps } from "./checkbox";
17
+ export { Dialog, type DialogProps } from "./dialog";
18
+ export { Input, type InputProps } from "./input";
19
+ export { Progress, type ProgressProps } from "./progress";
20
+ export { Select, type SelectProps } from "./select";
21
+ export { Skeleton, type SkeletonProps } from "./skeleton";
22
+ export { Spinner, type SpinnerProps } from "./spinner";
23
+ export { Switch, type SwitchProps } from "./switch";
24
+ export { Tabs, type TabItem, type TabsProps } from "./tabs";
25
+ export { Textarea, type TextareaProps } from "./textarea";
26
+ export { Tooltip, type TooltipProps } from "./tooltip";
package/src/input.tsx ADDED
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useId, type InputHTMLAttributes } from "react";
4
+ import { cx } from "./cx";
5
+ import field from "@imdz/styles/field.module.css";
6
+
7
+ export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
8
+ label?: string;
9
+ hint?: string;
10
+ error?: string;
11
+ };
12
+
13
+ export function Input({
14
+ label,
15
+ hint,
16
+ error,
17
+ id,
18
+ className,
19
+ ...props
20
+ }: InputProps) {
21
+ const generatedId = useId();
22
+ const inputId = id ?? generatedId;
23
+ const message = error || hint;
24
+ const messageId = `${inputId}-message`;
25
+
26
+ return (
27
+ <div className={field.field}>
28
+ {label && (
29
+ <label className={field.label} htmlFor={inputId}>
30
+ {label}
31
+ </label>
32
+ )}
33
+ <input
34
+ id={inputId}
35
+ className={cx(field.input, className)}
36
+ aria-invalid={error ? true : undefined}
37
+ aria-describedby={message ? messageId : undefined}
38
+ {...props}
39
+ />
40
+ {message && (
41
+ <p
42
+ id={messageId}
43
+ className={error ? field.error : field.hint}
44
+ >
45
+ {message}
46
+ </p>
47
+ )}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,45 @@
1
+ import type { CSSProperties } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/progress.module.css";
4
+
5
+ export type ProgressProps = {
6
+ value: number;
7
+ max?: number;
8
+ /** Shown above the bar next to the percentage. */
9
+ label?: string;
10
+ className?: string;
11
+ };
12
+
13
+ export function Progress({
14
+ value,
15
+ max = 100,
16
+ label,
17
+ className,
18
+ }: ProgressProps) {
19
+ const ratio = max > 0 ? value / max : 0;
20
+ const percent = Math.round(Math.min(Math.max(ratio, 0), 1) * 100);
21
+
22
+ return (
23
+ <div className={cx(styles.progress, className)}>
24
+ {label && (
25
+ <div className={styles.header}>
26
+ <span>{label}</span>
27
+ <span>{percent}%</span>
28
+ </div>
29
+ )}
30
+ <div
31
+ className={styles.track}
32
+ role="progressbar"
33
+ aria-label={label}
34
+ aria-valuemin={0}
35
+ aria-valuemax={max}
36
+ aria-valuenow={value}
37
+ >
38
+ <div
39
+ className={styles.bar}
40
+ style={{ "--imdz-progress-value": `${percent}%` } as CSSProperties}
41
+ />
42
+ </div>
43
+ </div>
44
+ );
45
+ }
package/src/select.tsx ADDED
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useId, type SelectHTMLAttributes } from "react";
4
+ import { cx } from "./cx";
5
+ import field from "@imdz/styles/field.module.css";
6
+ import styles from "@imdz/styles/select.module.css";
7
+
8
+ export type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
9
+ label?: string;
10
+ hint?: string;
11
+ error?: string;
12
+ };
13
+
14
+ /** Options go as children: regular option elements. */
15
+ export function Select({
16
+ label,
17
+ hint,
18
+ error,
19
+ id,
20
+ className,
21
+ children,
22
+ ...props
23
+ }: SelectProps) {
24
+ const generatedId = useId();
25
+ const inputId = id ?? generatedId;
26
+ const message = error || hint;
27
+ const messageId = `${inputId}-message`;
28
+
29
+ return (
30
+ <div className={field.field}>
31
+ {label && (
32
+ <label className={field.label} htmlFor={inputId}>
33
+ {label}
34
+ </label>
35
+ )}
36
+ <select
37
+ id={inputId}
38
+ className={cx(field.input, styles.select, className)}
39
+ aria-invalid={error ? true : undefined}
40
+ aria-describedby={message ? messageId : undefined}
41
+ {...props}
42
+ >
43
+ {children}
44
+ </select>
45
+ {message && (
46
+ <p
47
+ id={messageId}
48
+ className={error ? field.error : field.hint}
49
+ >
50
+ {message}
51
+ </p>
52
+ )}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,20 @@
1
+ import type { CSSProperties } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/skeleton.module.css";
4
+
5
+ export type SkeletonProps = {
6
+ width?: CSSProperties["width"];
7
+ height?: CSSProperties["height"];
8
+ className?: string;
9
+ };
10
+
11
+ /** Loading placeholder. */
12
+ export function Skeleton({ width, height, className }: SkeletonProps) {
13
+ return (
14
+ <span
15
+ aria-hidden="true"
16
+ className={cx(styles.skeleton, className)}
17
+ style={{ width, height }}
18
+ />
19
+ );
20
+ }
@@ -0,0 +1,23 @@
1
+ import { cx } from "./cx";
2
+ import styles from "@imdz/styles/spinner.module.css";
3
+
4
+ export type SpinnerProps = {
5
+ /** Text for screen readers. */
6
+ label?: string;
7
+ size?: "md" | "lg";
8
+ className?: string;
9
+ };
10
+
11
+ export function Spinner({
12
+ label = "Loading",
13
+ size = "md",
14
+ className,
15
+ }: SpinnerProps) {
16
+ return (
17
+ <span
18
+ role="status"
19
+ aria-label={label}
20
+ className={cx(styles.spinner, size === "lg" && styles.lg, className)}
21
+ />
22
+ );
23
+ }
package/src/switch.tsx ADDED
@@ -0,0 +1,23 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/switch.module.css";
4
+
5
+ export type SwitchProps = Omit<
6
+ InputHTMLAttributes<HTMLInputElement>,
7
+ "type" | "role"
8
+ >;
9
+
10
+ /** The switch label goes as children. */
11
+ export function Switch({ className, children, ...props }: SwitchProps) {
12
+ return (
13
+ <label className={cx(styles.switch, className)}>
14
+ <input
15
+ type="checkbox"
16
+ role="switch"
17
+ className={styles.input}
18
+ {...props}
19
+ />
20
+ <span>{children}</span>
21
+ </label>
22
+ );
23
+ }
package/src/tabs.tsx ADDED
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { cx } from "./cx";
11
+ import styles from "@imdz/styles/tabs.module.css";
12
+
13
+ export type TabItem = {
14
+ label: string;
15
+ content: ReactNode;
16
+ };
17
+
18
+ export type TabsProps = {
19
+ items: TabItem[];
20
+ defaultIndex?: number;
21
+ onChange?: (index: number) => void;
22
+ className?: string;
23
+ };
24
+
25
+ export function Tabs({
26
+ items,
27
+ defaultIndex = 0,
28
+ onChange,
29
+ className,
30
+ }: TabsProps) {
31
+ const [selected, setSelected] = useState(defaultIndex);
32
+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
33
+ const baseId = useId();
34
+
35
+ function select(index: number) {
36
+ setSelected(index);
37
+ onChange?.(index);
38
+ }
39
+
40
+ function handleKeyDown(event: KeyboardEvent) {
41
+ const last = items.length - 1;
42
+ const next = {
43
+ ArrowRight: selected === last ? 0 : selected + 1,
44
+ ArrowLeft: selected === 0 ? last : selected - 1,
45
+ Home: 0,
46
+ End: last,
47
+ }[event.key];
48
+
49
+ if (next === undefined) return;
50
+ event.preventDefault();
51
+ select(next);
52
+ tabRefs.current[next]?.focus();
53
+ }
54
+
55
+ return (
56
+ <div className={cx(styles.tabs, className)}>
57
+ <div className={styles.list} role="tablist" onKeyDown={handleKeyDown}>
58
+ {items.map((item, index) => (
59
+ <button
60
+ key={item.label}
61
+ ref={(element) => {
62
+ tabRefs.current[index] = element;
63
+ }}
64
+ type="button"
65
+ role="tab"
66
+ id={`${baseId}-tab-${index}`}
67
+ className={styles.tab}
68
+ aria-selected={index === selected}
69
+ aria-controls={`${baseId}-panel-${index}`}
70
+ tabIndex={index === selected ? 0 : -1}
71
+ onClick={() => select(index)}
72
+ >
73
+ {item.label}
74
+ </button>
75
+ ))}
76
+ </div>
77
+ {items.map((item, index) => (
78
+ <div
79
+ key={item.label}
80
+ role="tabpanel"
81
+ id={`${baseId}-panel-${index}`}
82
+ className={styles.panel}
83
+ aria-labelledby={`${baseId}-tab-${index}`}
84
+ hidden={index !== selected}
85
+ >
86
+ {item.content}
87
+ </div>
88
+ ))}
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useId, type TextareaHTMLAttributes } from "react";
4
+ import { cx } from "./cx";
5
+ import field from "@imdz/styles/field.module.css";
6
+ import styles from "@imdz/styles/textarea.module.css";
7
+
8
+ export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
9
+ label?: string;
10
+ hint?: string;
11
+ error?: string;
12
+ };
13
+
14
+ export function Textarea({
15
+ label,
16
+ hint,
17
+ error,
18
+ id,
19
+ rows = 4,
20
+ className,
21
+ ...props
22
+ }: TextareaProps) {
23
+ const generatedId = useId();
24
+ const inputId = id ?? generatedId;
25
+ const message = error || hint;
26
+ const messageId = `${inputId}-message`;
27
+
28
+ return (
29
+ <div className={field.field}>
30
+ {label && (
31
+ <label className={field.label} htmlFor={inputId}>
32
+ {label}
33
+ </label>
34
+ )}
35
+ <textarea
36
+ id={inputId}
37
+ rows={rows}
38
+ className={cx(field.input, styles.textarea, className)}
39
+ aria-invalid={error ? true : undefined}
40
+ aria-describedby={message ? messageId : undefined}
41
+ {...props}
42
+ />
43
+ {message && (
44
+ <p
45
+ id={messageId}
46
+ className={error ? field.error : field.hint}
47
+ >
48
+ {message}
49
+ </p>
50
+ )}
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import { cx } from "./cx";
3
+ import styles from "@imdz/styles/tooltip.module.css";
4
+
5
+ export type TooltipProps = {
6
+ text: string;
7
+ className?: string;
8
+ children: ReactNode;
9
+ };
10
+
11
+ /** Wraps an element and shows a short text on hover or focus. */
12
+ export function Tooltip({ text, className, children }: TooltipProps) {
13
+ return (
14
+ <span className={cx(styles.tooltip, className)}>
15
+ {children}
16
+ <span className={styles.bubble} role="tooltip">
17
+ {text}
18
+ </span>
19
+ </span>
20
+ );
21
+ }