gbs-add-block 1.2.13 → 1.2.15

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.
Files changed (105) hide show
  1. package/README.md +4 -12
  2. package/index.cjs +9 -0
  3. package/package.json +1 -1
  4. package/source/beta-components/breadcrumb/README.md +34 -0
  5. package/source/beta-components/breadcrumb/__tests__/core.test.ts +58 -0
  6. package/source/beta-components/breadcrumb/core/index.ts +4 -0
  7. package/source/beta-components/breadcrumb/core/trail.ts +42 -0
  8. package/source/beta-components/breadcrumb/core/types.ts +20 -0
  9. package/source/beta-components/breadcrumb/index.ts +5 -0
  10. package/source/beta-components/breadcrumb/react/Breadcrumb.tsx +177 -0
  11. package/source/beta-components/breadcrumb/react/locale.ts +6 -0
  12. package/source/beta-components/breadcrumb/react/props.ts +14 -0
  13. package/source/beta-components/breadcrumb/styles.css +139 -0
  14. package/source/beta-components/button/README.md +32 -0
  15. package/source/beta-components/button/__tests__/core.test.ts +31 -0
  16. package/source/beta-components/button/core/index.ts +3 -0
  17. package/source/beta-components/button/core/state.ts +16 -0
  18. package/source/beta-components/button/core/types.ts +28 -0
  19. package/source/beta-components/button/index.ts +5 -0
  20. package/source/beta-components/button/react/Button.tsx +173 -0
  21. package/source/beta-components/button/react/locale.ts +5 -0
  22. package/source/beta-components/button/react/props.ts +23 -0
  23. package/source/beta-components/button/styles.css +233 -0
  24. package/source/beta-components/checkbox/README.md +36 -0
  25. package/source/beta-components/checkbox/__tests__/core.test.ts +40 -0
  26. package/source/beta-components/checkbox/core/group.ts +30 -0
  27. package/source/beta-components/checkbox/core/index.ts +3 -0
  28. package/source/beta-components/checkbox/core/types.ts +17 -0
  29. package/source/beta-components/checkbox/index.ts +9 -0
  30. package/source/beta-components/checkbox/react/Checkbox.tsx +146 -0
  31. package/source/beta-components/checkbox/react/CheckboxGroup.tsx +149 -0
  32. package/source/beta-components/checkbox/react/context.ts +16 -0
  33. package/source/beta-components/checkbox/react/locale.ts +5 -0
  34. package/source/beta-components/checkbox/react/props.ts +6 -0
  35. package/source/beta-components/checkbox/styles.css +212 -0
  36. package/source/beta-components/dialog/README.md +39 -0
  37. package/source/beta-components/dialog/__tests__/core.test.ts +86 -0
  38. package/source/beta-components/dialog/core/api.ts +34 -0
  39. package/source/beta-components/dialog/core/dialog.ts +11 -0
  40. package/source/beta-components/dialog/core/index.ts +7 -0
  41. package/source/beta-components/dialog/core/store.ts +81 -0
  42. package/source/beta-components/dialog/core/types.ts +59 -0
  43. package/source/beta-components/dialog/index.ts +7 -0
  44. package/source/beta-components/dialog/react/Dialog.tsx +279 -0
  45. package/source/beta-components/dialog/react/DialogHost.tsx +45 -0
  46. package/source/beta-components/dialog/react/icons.tsx +51 -0
  47. package/source/beta-components/dialog/react/locale.ts +8 -0
  48. package/source/beta-components/dialog/react/props.ts +13 -0
  49. package/source/beta-components/dialog/styles.css +278 -0
  50. package/source/beta-components/input/README.md +44 -0
  51. package/source/beta-components/input/__tests__/core.test.ts +75 -0
  52. package/source/beta-components/input/core/count.ts +14 -0
  53. package/source/beta-components/input/core/index.ts +11 -0
  54. package/source/beta-components/input/core/otp.ts +64 -0
  55. package/source/beta-components/input/core/types.ts +14 -0
  56. package/source/beta-components/input/index.ts +8 -0
  57. package/source/beta-components/input/react/Input.tsx +219 -0
  58. package/source/beta-components/input/react/OtpInput.tsx +256 -0
  59. package/source/beta-components/input/react/dom.ts +10 -0
  60. package/source/beta-components/input/react/icons.tsx +35 -0
  61. package/source/beta-components/input/react/locale.ts +9 -0
  62. package/source/beta-components/input/react/props.ts +6 -0
  63. package/source/beta-components/input/styles.css +296 -0
  64. package/source/beta-components/modal/README.md +51 -0
  65. package/source/beta-components/modal/__tests__/core.test.ts +55 -0
  66. package/source/beta-components/modal/core/dismiss.ts +29 -0
  67. package/source/beta-components/modal/core/index.ts +3 -0
  68. package/source/beta-components/modal/core/types.ts +27 -0
  69. package/source/beta-components/modal/index.ts +5 -0
  70. package/source/beta-components/modal/react/Modal.tsx +238 -0
  71. package/source/beta-components/modal/react/icons.tsx +19 -0
  72. package/source/beta-components/modal/react/locale.ts +5 -0
  73. package/source/beta-components/modal/react/props.ts +17 -0
  74. package/source/beta-components/modal/styles.css +235 -0
  75. package/source/beta-components/spinner/README.md +29 -0
  76. package/source/beta-components/spinner/__tests__/core.test.ts +48 -0
  77. package/source/beta-components/spinner/core/index.ts +3 -0
  78. package/source/beta-components/spinner/core/types.ts +28 -0
  79. package/source/beta-components/spinner/core/visibility.ts +34 -0
  80. package/source/beta-components/spinner/index.ts +6 -0
  81. package/source/beta-components/spinner/react/Spinner.tsx +96 -0
  82. package/source/beta-components/spinner/react/locale.ts +5 -0
  83. package/source/beta-components/spinner/react/props.ts +4 -0
  84. package/source/beta-components/spinner/react/useDelayedLoading.ts +39 -0
  85. package/source/beta-components/spinner/styles.css +167 -0
  86. package/source/beta-components/tabs/README.md +32 -0
  87. package/source/beta-components/tabs/__tests__/core.test.ts +51 -0
  88. package/source/beta-components/tabs/core/index.ts +3 -0
  89. package/source/beta-components/tabs/core/navigation.ts +54 -0
  90. package/source/beta-components/tabs/core/types.ts +16 -0
  91. package/source/beta-components/tabs/index.ts +5 -0
  92. package/source/beta-components/tabs/react/Tabs.tsx +260 -0
  93. package/source/beta-components/tabs/react/context.ts +21 -0
  94. package/source/beta-components/tabs/styles.css +257 -0
  95. package/source/beta-components/textarea/README.md +39 -0
  96. package/source/beta-components/textarea/__tests__/core.test.ts +38 -0
  97. package/source/beta-components/textarea/core/count.ts +14 -0
  98. package/source/beta-components/textarea/core/index.ts +4 -0
  99. package/source/beta-components/textarea/core/size.ts +23 -0
  100. package/source/beta-components/textarea/core/types.ts +17 -0
  101. package/source/beta-components/textarea/index.ts +5 -0
  102. package/source/beta-components/textarea/react/Textarea.tsx +209 -0
  103. package/source/beta-components/textarea/react/locale.ts +5 -0
  104. package/source/beta-components/textarea/react/props.ts +4 -0
  105. package/source/beta-components/textarea/styles.css +159 -0
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type CSSProperties,
12
+ type MouseEvent,
13
+ type ReactNode,
14
+ type Ref,
15
+ type RefObject,
16
+ } from "react";
17
+ import { canDismiss, confirmClose, isOutside } from "../core/dismiss";
18
+ import type {
19
+ CloseReason,
20
+ ModalLocaleText,
21
+ ModalPlacement,
22
+ ModalSize,
23
+ } from "../core/types";
24
+ import { XIcon } from "./icons";
25
+ import { defaultModalText } from "./locale";
26
+ import { cx, type ModalHandle, type ModalRenderProps, type ModalSlot } from "./props";
27
+
28
+ /** Matches the CSS transition, so content stays mounted while the modal animates out. */
29
+ const EXIT_DURATION = 200;
30
+
31
+ type Renderable = ReactNode | ((props: ModalRenderProps) => ReactNode);
32
+
33
+ export interface ModalProps {
34
+ /** Controlled open state. */
35
+ open?: boolean;
36
+ defaultOpen?: boolean;
37
+ /** `reason` says what closed it; it is undefined when opening. */
38
+ onOpenChange?(open: boolean, reason?: CloseReason): void;
39
+ title?: ReactNode;
40
+ description?: ReactNode;
41
+ /** Accessible name when there is no `title`. */
42
+ "aria-label"?: string;
43
+ children?: Renderable;
44
+ footer?: Renderable;
45
+ /** Width for dialogs; for drawers, the width (left/right) or height (bottom). Default `md`. */
46
+ size?: ModalSize;
47
+ /** Default `center`. */
48
+ placement?: ModalPlacement;
49
+ /** Show the × button. Default true. */
50
+ closeButton?: boolean;
51
+ /** Default true. */
52
+ closeOnEscape?: boolean;
53
+ /** Default true. */
54
+ closeOnBackdrop?: boolean;
55
+ /** Return `false` (or a promise of it) to keep the modal open, e.g. for unsaved changes. */
56
+ onBeforeClose?(reason: CloseReason): boolean | Promise<boolean>;
57
+ /** Focused on open. Otherwise the first `[data-autofocus]` element, then the first focusable one. */
58
+ initialFocus?: RefObject<HTMLElement | null>;
59
+ /** Keep the content mounted while closed, preserving its state. Default false. */
60
+ keepMounted?: boolean;
61
+ id?: string;
62
+ className?: string;
63
+ classNames?: Partial<Record<ModalSlot, string>>;
64
+ style?: CSSProperties;
65
+ localeText?: Partial<ModalLocaleText>;
66
+ ref?: Ref<ModalHandle>;
67
+ }
68
+
69
+ const outside = (event: MouseEvent<HTMLDialogElement>) =>
70
+ event.target === event.currentTarget &&
71
+ isOutside(event.currentTarget.getBoundingClientRect(), event.clientX, event.clientY);
72
+
73
+ /**
74
+ * A modal built on the native `<dialog>` element. The browser provides the top
75
+ * layer, the inert background, the focus trap and focus return; the component
76
+ * adds controlled state, dismiss rules, drawers and animation.
77
+ */
78
+ export function Modal(props: ModalProps) {
79
+ const {
80
+ ref,
81
+ open: openProp,
82
+ defaultOpen = false,
83
+ onOpenChange,
84
+ title,
85
+ description,
86
+ "aria-label": ariaLabel,
87
+ children,
88
+ footer,
89
+ size = "md",
90
+ placement = "center",
91
+ closeButton = true,
92
+ closeOnEscape = true,
93
+ closeOnBackdrop = true,
94
+ onBeforeClose,
95
+ initialFocus,
96
+ keepMounted = false,
97
+ id: idProp,
98
+ className,
99
+ classNames,
100
+ style,
101
+ localeText,
102
+ } = props;
103
+
104
+ const reactId = useId();
105
+ const id = idProp ?? reactId;
106
+ const text = useMemo(() => ({ ...defaultModalText, ...localeText }), [localeText]);
107
+ const dialogRef = useRef<HTMLDialogElement>(null);
108
+ const pressStartedOutside = useRef(false);
109
+
110
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
111
+ const open = openProp ?? internalOpen;
112
+
113
+ // Keep the content mounted through the exit transition. Adjusting state while
114
+ // rendering avoids an extra render with the content already gone.
115
+ const [previousOpen, setPreviousOpen] = useState(open);
116
+ const [closing, setClosing] = useState(false);
117
+ if (previousOpen !== open) {
118
+ setPreviousOpen(open);
119
+ setClosing(!open);
120
+ }
121
+
122
+ useEffect(() => {
123
+ if (!closing) return;
124
+ const timer = setTimeout(() => setClosing(false), EXIT_DURATION);
125
+ return () => clearTimeout(timer);
126
+ }, [closing]);
127
+
128
+ const setOpen = useCallback(
129
+ (next: boolean, reason?: CloseReason) => {
130
+ if (openProp === undefined) setInternalOpen(next);
131
+ onOpenChange?.(next, reason);
132
+ },
133
+ [openProp, onOpenChange],
134
+ );
135
+
136
+ const requestClose = useCallback(
137
+ (reason: CloseReason) => {
138
+ if (!canDismiss(reason, { closeOnEscape, closeOnBackdrop })) return;
139
+ void confirmClose(onBeforeClose, reason).then((allowed) => {
140
+ if (allowed) setOpen(false, reason);
141
+ });
142
+ },
143
+ [closeOnEscape, closeOnBackdrop, onBeforeClose, setOpen],
144
+ );
145
+
146
+ useEffect(() => {
147
+ const dialog = dialogRef.current;
148
+ if (!dialog) return;
149
+ if (open && !dialog.open) {
150
+ dialog.showModal();
151
+ const target =
152
+ initialFocus?.current ?? dialog.querySelector<HTMLElement>("[data-autofocus]");
153
+ target?.focus();
154
+ } else if (!open && dialog.open) {
155
+ dialog.close();
156
+ }
157
+ }, [open, initialFocus]);
158
+
159
+ useImperativeHandle(
160
+ ref,
161
+ () => ({
162
+ open: () => setOpen(true),
163
+ close: () => requestClose("api"),
164
+ getElement: () => dialogRef.current,
165
+ }),
166
+ [setOpen, requestClose],
167
+ );
168
+
169
+ const renderProps: ModalRenderProps = { close: () => requestClose("api") };
170
+ const render = (node: Renderable) => (typeof node === "function" ? node(renderProps) : node);
171
+ const mounted = open || closing || keepMounted;
172
+
173
+ return (
174
+ <dialog
175
+ ref={dialogRef}
176
+ id={id}
177
+ className={cx("md-root", classNames?.root, className)}
178
+ style={style}
179
+ data-size={size}
180
+ data-placement={placement}
181
+ data-state={open ? "open" : "closed"}
182
+ aria-labelledby={title ? `${id}-title` : undefined}
183
+ aria-describedby={description ? `${id}-description` : undefined}
184
+ aria-label={title ? undefined : ariaLabel}
185
+ // Escape: keep the browser from closing it, and decide ourselves.
186
+ onCancel={(event) => {
187
+ event.preventDefault();
188
+ requestClose("escape");
189
+ }}
190
+ // Closed some other way, e.g. by a `<form method="dialog">` inside.
191
+ onClose={() => {
192
+ if (open) setOpen(false, "api");
193
+ }}
194
+ // A drag that starts inside and ends on the backdrop must not close it.
195
+ onPointerDown={(event) => {
196
+ pressStartedOutside.current = outside(event);
197
+ }}
198
+ onClick={(event) => {
199
+ if (pressStartedOutside.current && outside(event)) requestClose("backdrop");
200
+ pressStartedOutside.current = false;
201
+ }}
202
+ >
203
+ {mounted && (
204
+ <>
205
+ {(title || description || closeButton) && (
206
+ <header className={cx("md-header", classNames?.header)}>
207
+ <div className="md-heading">
208
+ {title && (
209
+ <h2 id={`${id}-title`} className={cx("md-title", classNames?.title)}>
210
+ {title}
211
+ </h2>
212
+ )}
213
+ {description && (
214
+ <p id={`${id}-description`} className={cx("md-description", classNames?.description)}>
215
+ {description}
216
+ </p>
217
+ )}
218
+ </div>
219
+ {closeButton && (
220
+ <button
221
+ type="button"
222
+ className={cx("md-close", classNames?.close)}
223
+ aria-label={text.close}
224
+ title={text.close}
225
+ onClick={() => requestClose("close-button")}
226
+ >
227
+ <XIcon />
228
+ </button>
229
+ )}
230
+ </header>
231
+ )}
232
+ <div className={cx("md-body", classNames?.body)}>{render(children)}</div>
233
+ {footer && <footer className={cx("md-footer", classNames?.footer)}>{render(footer)}</footer>}
234
+ </>
235
+ )}
236
+ </dialog>
237
+ );
238
+ }
@@ -0,0 +1,19 @@
1
+ import type { SVGProps } from "react";
2
+
3
+ export const XIcon = (props: SVGProps<SVGSVGElement>) => (
4
+ <svg
5
+ width="16"
6
+ height="16"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ strokeWidth="2"
11
+ strokeLinecap="round"
12
+ strokeLinejoin="round"
13
+ aria-hidden="true"
14
+ focusable="false"
15
+ {...props}
16
+ >
17
+ <path d="M18 6 6 18M6 6l12 12" />
18
+ </svg>
19
+ );
@@ -0,0 +1,5 @@
1
+ import type { ModalLocaleText } from "../core/types";
2
+
3
+ export const defaultModalText: ModalLocaleText = {
4
+ close: "Close",
5
+ };
@@ -0,0 +1,17 @@
1
+ export type ModalSlot = "root" | "header" | "title" | "description" | "close" | "body" | "footer";
2
+
3
+ export interface ModalHandle {
4
+ open(): void;
5
+ /** Asks to close; `onBeforeClose` still gets a say. */
6
+ close(): void;
7
+ getElement(): HTMLDialogElement | null;
8
+ }
9
+
10
+ /** Passed to `children` and `footer` when they are functions. */
11
+ export interface ModalRenderProps {
12
+ /** Closes through `onBeforeClose`, like the close button. */
13
+ close(): void;
14
+ }
15
+
16
+ export const cx = (...names: (string | false | null | undefined)[]) =>
17
+ names.filter(Boolean).join(" ");
@@ -0,0 +1,235 @@
1
+ /*
2
+ * Modal styles.
3
+ *
4
+ * Tokens read the DataGrid's --dg-* variables when they are set on an ancestor
5
+ * (set them on :root to share one theme) and fall back to the same palette
6
+ * otherwise. Rules live in the `components` layer, so utility classes passed
7
+ * through `className` / `classNames` override them.
8
+ */
9
+ @layer theme, base, components, utilities;
10
+
11
+ @layer components {
12
+ .md-root {
13
+ --md-bg: var(--dg-bg, light-dark(#ffffff, #0b0b0e));
14
+ --md-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
15
+ --md-muted: var(--dg-muted, light-dark(#71717a, #a1a1aa));
16
+ --md-border: var(--dg-border, light-dark(#e4e4e7, #27272a));
17
+ --md-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
18
+ --md-footer-bg: var(--dg-header-bg, light-dark(#fafafa, #111114));
19
+ --md-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
20
+ --md-shadow: 0 24px 64px -16px light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.8));
21
+ --md-backdrop: light-dark(rgb(9 9 11 / 0.45), rgb(0 0 0 / 0.65));
22
+ --md-radius: var(--dg-radius, 12px);
23
+ --md-font-size: var(--dg-font-size, 13px);
24
+ --md-width: 520px;
25
+ --md-gutter: 16px;
26
+ --md-px: 20px;
27
+ --md-duration: 200ms;
28
+ --md-enter: 0 8px;
29
+
30
+ color-scheme: inherit;
31
+ box-sizing: border-box;
32
+ width: min(var(--md-width), 100vw - 2 * var(--md-gutter));
33
+ max-width: none;
34
+ max-height: calc(100dvh - 2 * var(--md-gutter));
35
+ margin: auto;
36
+ padding: 0;
37
+ overflow: hidden;
38
+ border: 1px solid var(--md-border);
39
+ border-radius: var(--md-radius);
40
+ background: var(--md-bg);
41
+ color: var(--md-fg);
42
+ box-shadow: var(--md-shadow);
43
+ font-size: var(--md-font-size);
44
+ line-height: 1.5;
45
+ opacity: 0;
46
+ translate: var(--md-enter);
47
+ transition:
48
+ opacity var(--md-duration) ease,
49
+ translate var(--md-duration) ease,
50
+ overlay var(--md-duration) allow-discrete,
51
+ display var(--md-duration) allow-discrete;
52
+ }
53
+ .md-root[open] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ opacity: 1;
57
+ translate: 0 0;
58
+ }
59
+ @starting-style {
60
+ .md-root[open] {
61
+ opacity: 0;
62
+ translate: var(--md-enter);
63
+ }
64
+ }
65
+
66
+ .md-root::backdrop {
67
+ background: var(--md-backdrop, rgb(9 9 11 / 0.45));
68
+ opacity: 0;
69
+ transition:
70
+ opacity var(--md-duration) ease,
71
+ overlay var(--md-duration) allow-discrete,
72
+ display var(--md-duration) allow-discrete;
73
+ }
74
+ .md-root[open]::backdrop {
75
+ opacity: 1;
76
+ }
77
+ @starting-style {
78
+ .md-root[open]::backdrop {
79
+ opacity: 0;
80
+ }
81
+ }
82
+
83
+ :where(.dark, [data-theme="dark"]) .md-root {
84
+ color-scheme: dark;
85
+ }
86
+ :where(.light, [data-theme="light"]) .md-root {
87
+ color-scheme: light;
88
+ }
89
+
90
+ :where(.md-root) *,
91
+ :where(.md-root) *::before,
92
+ :where(.md-root) *::after {
93
+ box-sizing: border-box;
94
+ }
95
+
96
+ /* The page behind an open modal doesn't scroll. */
97
+ :root:has(.md-root[open]) {
98
+ overflow: hidden;
99
+ }
100
+
101
+ /* --------------------------------------------------------------- sizes */
102
+
103
+ .md-root[data-size="sm"] {
104
+ --md-width: 400px;
105
+ }
106
+ .md-root[data-size="lg"] {
107
+ --md-width: 720px;
108
+ }
109
+ .md-root[data-size="xl"] {
110
+ --md-width: 960px;
111
+ }
112
+ .md-root[data-size="full"] {
113
+ --md-width: 100vw;
114
+ --md-gutter: 0px;
115
+ height: 100dvh;
116
+ max-height: 100dvh;
117
+ border: 0;
118
+ border-radius: 0;
119
+ }
120
+
121
+ /* ----------------------------------------------------------- placement */
122
+
123
+ .md-root[data-placement="top"] {
124
+ margin-top: min(10vh, 80px);
125
+ }
126
+
127
+ .md-root:is([data-placement="left"], [data-placement="right"]) {
128
+ --md-gutter: 0px;
129
+ height: 100dvh;
130
+ max-height: 100dvh;
131
+ border-block: 0;
132
+ border-radius: 0;
133
+ }
134
+ .md-root[data-placement="left"] {
135
+ --md-enter: -100% 0;
136
+ margin: 0 auto 0 0;
137
+ border-inline-start: 0;
138
+ }
139
+ .md-root[data-placement="right"] {
140
+ --md-enter: 100% 0;
141
+ margin: 0 0 0 auto;
142
+ border-inline-end: 0;
143
+ }
144
+ .md-root[data-placement="bottom"] {
145
+ --md-enter: 0 100%;
146
+ width: 100vw;
147
+ max-height: min(var(--md-width), 90dvh);
148
+ margin: auto 0 0;
149
+ border-bottom: 0;
150
+ border-radius: var(--md-radius) var(--md-radius) 0 0;
151
+ }
152
+
153
+ /* -------------------------------------------------------------- layout */
154
+
155
+ .md-header {
156
+ display: flex;
157
+ flex: none;
158
+ align-items: flex-start;
159
+ gap: 12px;
160
+ padding: 16px 12px 12px var(--md-px);
161
+ }
162
+ .md-heading {
163
+ flex: 1 1 auto;
164
+ min-width: 0;
165
+ }
166
+ .md-title {
167
+ margin: 0;
168
+ font-size: 1.15em;
169
+ font-weight: 600;
170
+ line-height: 1.35;
171
+ }
172
+ .md-description {
173
+ margin: 4px 0 0;
174
+ color: var(--md-muted);
175
+ }
176
+ .md-close {
177
+ display: inline-grid;
178
+ flex: none;
179
+ place-items: center;
180
+ width: 30px;
181
+ height: 30px;
182
+ margin-top: -4px;
183
+ padding: 0;
184
+ border: 0;
185
+ border-radius: 6px;
186
+ background: transparent;
187
+ color: var(--md-muted);
188
+ cursor: pointer;
189
+ }
190
+ .md-close:hover {
191
+ background: var(--md-hover);
192
+ color: var(--md-fg);
193
+ }
194
+ .md-close:focus-visible {
195
+ outline: 2px solid var(--md-focus);
196
+ outline-offset: 1px;
197
+ }
198
+
199
+ .md-body {
200
+ flex: 1 1 auto;
201
+ min-height: 0;
202
+ padding: 0 var(--md-px) 20px;
203
+ overflow: auto;
204
+ overscroll-behavior: contain;
205
+ }
206
+ .md-body:first-child {
207
+ padding-top: 20px;
208
+ }
209
+
210
+ .md-footer {
211
+ display: flex;
212
+ flex: none;
213
+ flex-wrap: wrap;
214
+ align-items: center;
215
+ justify-content: flex-end;
216
+ gap: 8px;
217
+ padding: 12px var(--md-px);
218
+ border-top: 1px solid var(--md-border);
219
+ background: var(--md-footer-bg);
220
+ }
221
+
222
+ @media (prefers-reduced-motion: reduce) {
223
+ .md-root,
224
+ .md-root::backdrop {
225
+ --md-enter: 0 0;
226
+ transition-duration: 0s;
227
+ }
228
+ }
229
+
230
+ @media (forced-colors: active) {
231
+ .md-root {
232
+ border: 1px solid CanvasText;
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,29 @@
1
+ # Spinner
2
+
3
+ A loading indicator for React 19, on its own or covering busy content, styled to
4
+ match the rest of the library. No runtime dependencies besides React.
5
+
6
+ ```ts
7
+ import { Spinner, useDelayedLoading } from "@/components/spinner";
8
+ import "@/components/spinner/styles.css";
9
+ ```
10
+
11
+ ```tsx
12
+ <Spinner /> {/* 24 px ring in the current text color */}
13
+ <Spinner size="sm" showLabel label="Saving…" />
14
+ <Spinner loading={isPending} delay={250} minDuration={600}>
15
+ <Chart /> {/* covered and inert while loading */}
16
+ </Spinner>
17
+ ```
18
+
19
+ | Prop | Default | Description |
20
+ | --- | --- | --- |
21
+ | `loading` | `true` | With children, covers them while true. |
22
+ | `size` | `md` | `xs` 12 · `sm` 16 · `md` 24 · `lg` 32 · `xl` 48, or pixels. |
23
+ | `variant` | `ring` | `ring` or `dots`. |
24
+ | `label` / `showLabel` | "Loading" / `false` | Announced text; optionally shown. |
25
+ | `delay` / `minDuration` | `0` / `0` | Wait before showing; stay at least this long once shown. |
26
+ | `className`, `classNames`, `style`, `localeText` | — | Slots: `root`, `status`, `indicator`, `label`, `content`, `overlay`. |
27
+
28
+ `useDelayedLoading(loading, { delay, minDuration })` applies the same timing to
29
+ any loading UI. `stepVisibility` is the framework-free rule behind it.
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { spinnerPixels, stepVisibility } from "../core/visibility";
3
+
4
+ const hidden = { visible: false, loadingSince: null, visibleSince: null };
5
+
6
+ describe("visibility", () => {
7
+ it("shows at once without a delay", () => {
8
+ expect(stepVisibility(true, { ...hidden, loadingSince: 1000 }, 1000)).toEqual({ visible: true, recheckIn: null });
9
+ });
10
+
11
+ it("waits out the delay, then shows", () => {
12
+ const marks = { ...hidden, loadingSince: 1000 };
13
+ expect(stepVisibility(true, marks, 1100, { delay: 300 })).toEqual({ visible: false, recheckIn: 200 });
14
+ expect(stepVisibility(true, marks, 1300, { delay: 300 })).toEqual({ visible: true, recheckIn: null });
15
+ });
16
+
17
+ it("never shows for work that ends within the delay", () => {
18
+ expect(stepVisibility(false, hidden, 1200, { delay: 300 })).toEqual({ visible: false, recheckIn: null });
19
+ });
20
+
21
+ it("stays for the minimum duration once shown", () => {
22
+ const shown = { visible: true, loadingSince: null, visibleSince: 2000 };
23
+ expect(stepVisibility(false, shown, 2100, { minDuration: 500 })).toEqual({ visible: true, recheckIn: 400 });
24
+ expect(stepVisibility(false, shown, 2500, { minDuration: 500 })).toEqual({ visible: false, recheckIn: null });
25
+ });
26
+
27
+ it("keeps showing while loading continues", () => {
28
+ const shown = { visible: true, loadingSince: 1000, visibleSince: 1300 };
29
+ expect(stepVisibility(true, shown, 9000, { delay: 300, minDuration: 500 })).toEqual({
30
+ visible: true,
31
+ recheckIn: null,
32
+ });
33
+ });
34
+
35
+ it("treats negative options as zero", () => {
36
+ expect(stepVisibility(true, { ...hidden, loadingSince: 0 }, 0, { delay: -50 }).visible).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe("size", () => {
41
+ it("maps names to pixels and passes numbers through", () => {
42
+ expect(spinnerPixels()).toBe(24);
43
+ expect(spinnerPixels("xs")).toBe(12);
44
+ expect(spinnerPixels("xl")).toBe(48);
45
+ expect(spinnerPixels(20)).toBe(20);
46
+ expect(spinnerPixels(0)).toBe(1);
47
+ });
48
+ });
@@ -0,0 +1,3 @@
1
+ // Framework-free: when a spinner shows, and how big it is.
2
+ export { spinnerPixels, stepVisibility } from "./visibility";
3
+ export type * from "./types";
@@ -0,0 +1,28 @@
1
+ export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
2
+
3
+ export type SpinnerVariant = "ring" | "dots";
4
+
5
+ export interface VisibilityOptions {
6
+ /** Wait this long before showing, so fast work never flashes a spinner. Default 0. */
7
+ delay?: number;
8
+ /** Once shown, stay at least this long, so it doesn't blink off. Default 0. */
9
+ minDuration?: number;
10
+ }
11
+
12
+ export interface VisibilityMarks {
13
+ visible: boolean;
14
+ /** When `loading` last turned true, or null while not loading. */
15
+ loadingSince: number | null;
16
+ /** When the spinner last appeared, or null while hidden. */
17
+ visibleSince: number | null;
18
+ }
19
+
20
+ export interface VisibilityStep {
21
+ visible: boolean;
22
+ /** Milliseconds until the answer can change, or null when it won't on its own. */
23
+ recheckIn: number | null;
24
+ }
25
+
26
+ export interface SpinnerLocaleText {
27
+ loading: string;
28
+ }
@@ -0,0 +1,34 @@
1
+ import type { SpinnerSize, VisibilityMarks, VisibilityOptions, VisibilityStep } from "./types";
2
+
3
+ /**
4
+ * Whether a spinner should be on screen right now. A short `delay` hides work
5
+ * that finishes quickly; a `minDuration` keeps a spinner that did appear from
6
+ * vanishing a moment later. Both avoid the flicker of a spinner that blinks.
7
+ */
8
+ export function stepVisibility(
9
+ loading: boolean,
10
+ marks: VisibilityMarks,
11
+ now: number,
12
+ options: VisibilityOptions = {},
13
+ ): VisibilityStep {
14
+ const delay = Math.max(0, options.delay ?? 0);
15
+ const minDuration = Math.max(0, options.minDuration ?? 0);
16
+
17
+ if (loading && !marks.visible) {
18
+ const waited = now - (marks.loadingSince ?? now);
19
+ return waited >= delay ? { visible: true, recheckIn: null } : { visible: false, recheckIn: delay - waited };
20
+ }
21
+ if (!loading && marks.visible) {
22
+ const shown = now - (marks.visibleSince ?? now);
23
+ return shown >= minDuration
24
+ ? { visible: false, recheckIn: null }
25
+ : { visible: true, recheckIn: minDuration - shown };
26
+ }
27
+ return { visible: marks.visible, recheckIn: null };
28
+ }
29
+
30
+ const SIZES: Record<SpinnerSize, number> = { xs: 12, sm: 16, md: 24, lg: 32, xl: 48 };
31
+
32
+ /** Pixel size for a named size; numbers pass through. */
33
+ export const spinnerPixels = (size: SpinnerSize | number = "md") =>
34
+ typeof size === "number" ? Math.max(1, size) : SIZES[size];
@@ -0,0 +1,6 @@
1
+ export { Spinner } from "./react/Spinner";
2
+ export type { SpinnerProps } from "./react/Spinner";
3
+ export { useDelayedLoading } from "./react/useDelayedLoading";
4
+ export { defaultSpinnerText } from "./react/locale";
5
+ export type { SpinnerSlot } from "./react/props";
6
+ export * from "./core";