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,5 @@
1
+ export { Button } from "./react/Button";
2
+ export type { ButtonProps } from "./react/Button";
3
+ export { defaultButtonText } from "./react/locale";
4
+ export type { ButtonRenderProps, ButtonSlot } from "./react/props";
5
+ export * from "./core";
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ import {
4
+ useMemo,
5
+ useState,
6
+ type ButtonHTMLAttributes,
7
+ type MouseEvent,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ type Ref,
11
+ } from "react";
12
+ import { useFormStatus } from "react-dom";
13
+ import { buttonState, isPromiseLike } from "../core/state";
14
+ import type { ButtonLocaleText, ButtonSize, ButtonVariant } from "../core/types";
15
+ import { defaultButtonText } from "./locale";
16
+ import { cx, type ButtonRenderProps, type ButtonSlot } from "./props";
17
+
18
+ export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
19
+ /** Default `primary`. */
20
+ variant?: ButtonVariant;
21
+ /** Default `md`. */
22
+ size?: ButtonSize;
23
+ /** Show a spinner and ignore clicks, while keeping focus and the button's width. */
24
+ loading?: boolean;
25
+ /** Text shown next to the spinner while busy, instead of keeping the label. */
26
+ loadingText?: ReactNode;
27
+ /** Icon before the label. */
28
+ leading?: ReactNode;
29
+ /** Icon after the label. */
30
+ trailing?: ReactNode;
31
+ /** An icon-only button. Give it an `aria-label`. */
32
+ icon?: ReactNode;
33
+ fullWidth?: boolean;
34
+ /** Return a promise to show the loading state until it settles. */
35
+ onClick?(event: MouseEvent<HTMLElement>): unknown;
36
+ /**
37
+ * Render another element with the button's look and behavior, e.g. a router
38
+ * link: `render={(props) => <Link href="/billing" {...props} />}`.
39
+ */
40
+ render?(props: ButtonRenderProps): ReactElement;
41
+ classNames?: Partial<Record<ButtonSlot, string>>;
42
+ localeText?: Partial<ButtonLocaleText>;
43
+ ref?: Ref<HTMLButtonElement>;
44
+ }
45
+
46
+ /** Attributes that only mean something on a `<button>`, left off a `render`ed element. */
47
+ const BUTTON_ONLY = new Set(["form", "formAction", "formEncType", "formMethod", "formNoValidate", "formTarget", "name", "value", "type"]);
48
+
49
+ const Spinner = () => (
50
+ <svg className="bt-spinner-ring" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
51
+ <circle cx="12" cy="12" r="9" pathLength={100} />
52
+ </svg>
53
+ );
54
+
55
+ /**
56
+ * A button with variants, sizes, icons and a loading state. An async `onClick`
57
+ * shows progress by itself, and a submit button shows progress while its form's
58
+ * Server Action runs.
59
+ */
60
+ export function Button(props: ButtonProps) {
61
+ const {
62
+ ref,
63
+ variant = "primary",
64
+ size = "md",
65
+ type = "button",
66
+ loading = false,
67
+ loadingText,
68
+ leading,
69
+ trailing,
70
+ icon,
71
+ fullWidth = false,
72
+ disabled = false,
73
+ onClick,
74
+ render,
75
+ className,
76
+ classNames,
77
+ localeText,
78
+ children,
79
+ ...rest
80
+ } = props;
81
+
82
+ const text = useMemo(() => ({ ...defaultButtonText, ...localeText }), [localeText]);
83
+ // Pending while the surrounding <form action={…}> is submitting (React 19).
84
+ const { pending: formPending } = useFormStatus();
85
+ const [clickPending, setClickPending] = useState(false);
86
+ const state = buttonState({
87
+ disabled,
88
+ loading,
89
+ pending: clickPending || (type === "submit" && formPending),
90
+ });
91
+ const iconOnly = icon !== undefined && (children === undefined || children === null);
92
+
93
+ const handleClick = (event: MouseEvent<HTMLElement>) => {
94
+ // aria-disabled doesn't stop a click or a form submit on its own.
95
+ if (state.inactive) {
96
+ event.preventDefault();
97
+ return;
98
+ }
99
+ const result = onClick?.(event);
100
+ if (!isPromiseLike(result)) return;
101
+ setClickPending(true);
102
+ const done = () => setClickPending(false);
103
+ Promise.resolve(result).then(done, (error: unknown) => {
104
+ done();
105
+ // Keep the failure visible instead of swallowing it.
106
+ throw error;
107
+ });
108
+ };
109
+
110
+ const content = (
111
+ <>
112
+ <span
113
+ className={cx("bt-content", classNames?.content)}
114
+ // Hidden, not removed, so the button keeps its width while busy.
115
+ data-hidden={state.busy ? "" : undefined}
116
+ >
117
+ {leading && (
118
+ <span className={cx("bt-icon", classNames?.icon)} aria-hidden="true">
119
+ {leading}
120
+ </span>
121
+ )}
122
+ {iconOnly ? <span className={cx("bt-icon", classNames?.icon)}>{icon}</span> : children}
123
+ {trailing && (
124
+ <span className={cx("bt-icon", classNames?.icon)} aria-hidden="true">
125
+ {trailing}
126
+ </span>
127
+ )}
128
+ </span>
129
+ {state.busy && (
130
+ <span className={cx("bt-spinner", classNames?.spinner)}>
131
+ <Spinner />
132
+ {loadingText ?? <span className="bt-sr-only">{text.loading}</span>}
133
+ </span>
134
+ )}
135
+ </>
136
+ );
137
+
138
+ const shared = {
139
+ className: cx("bt-root", classNames?.root, className),
140
+ "data-variant": variant,
141
+ "data-size": size,
142
+ "data-busy": state.busy ? ("" as const) : undefined,
143
+ "data-icon-only": iconOnly ? ("" as const) : undefined,
144
+ "data-full-width": fullWidth ? ("" as const) : undefined,
145
+ "data-loading-text": state.busy && loadingText ? "" : undefined,
146
+ "aria-busy": state.busy || undefined,
147
+ };
148
+
149
+ if (render) {
150
+ const attributes = Object.fromEntries(Object.entries(rest).filter(([key]) => !BUTTON_ONLY.has(key)));
151
+ return render({
152
+ ...attributes,
153
+ ...shared,
154
+ "aria-disabled": state.inactive || undefined,
155
+ onClick: handleClick,
156
+ children: content,
157
+ });
158
+ }
159
+
160
+ return (
161
+ <button
162
+ {...rest}
163
+ {...shared}
164
+ ref={ref}
165
+ type={type}
166
+ disabled={state.nativeDisabled}
167
+ aria-disabled={state.inactive && !state.nativeDisabled ? true : undefined}
168
+ onClick={handleClick}
169
+ >
170
+ {content}
171
+ </button>
172
+ );
173
+ }
@@ -0,0 +1,5 @@
1
+ import type { ButtonLocaleText } from "../core/types";
2
+
3
+ export const defaultButtonText: ButtonLocaleText = {
4
+ loading: "Loading",
5
+ };
@@ -0,0 +1,23 @@
1
+ import type { CSSProperties, MouseEvent, ReactNode } from "react";
2
+ import type { ButtonSize, ButtonVariant } from "../core/types";
3
+
4
+ export type ButtonSlot = "root" | "content" | "spinner" | "icon";
5
+
6
+ /** Handed to `render`, to put the button's look and behavior on another element. */
7
+ export interface ButtonRenderProps {
8
+ className: string;
9
+ style?: CSSProperties;
10
+ children: ReactNode;
11
+ onClick(event: MouseEvent<HTMLElement>): void;
12
+ "data-variant": ButtonVariant;
13
+ "data-size": ButtonSize;
14
+ "data-busy"?: "";
15
+ "data-icon-only"?: "";
16
+ "data-full-width"?: "";
17
+ "aria-busy"?: boolean;
18
+ "aria-disabled"?: boolean;
19
+ [attribute: string]: unknown;
20
+ }
21
+
22
+ export const cx = (...names: (string | false | null | undefined)[]) =>
23
+ names.filter(Boolean).join(" ");
@@ -0,0 +1,233 @@
1
+ /*
2
+ * Button 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
+ .bt-root {
13
+ --bt-bg: var(--dg-bg, light-dark(#ffffff, #0b0b0e));
14
+ --bt-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
15
+ --bt-border: var(--dg-border, light-dark(#e4e4e7, #27272a));
16
+ --bt-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
17
+ --bt-subtle: light-dark(#f4f4f5, #1c1c20);
18
+ --bt-accent: var(--dg-accent, light-dark(#2563eb, #60a5fa));
19
+ --bt-accent-fg: var(--dg-accent-fg, light-dark(#ffffff, #0b1220));
20
+ --bt-danger: var(--dg-danger, light-dark(#dc2626, #f87171));
21
+ --bt-danger-fg: light-dark(#ffffff, #1c0606);
22
+ --bt-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
23
+ --bt-radius: var(--dg-radius, 8px);
24
+ --bt-font-size: var(--dg-font-size, 13px);
25
+ --bt-height: 36px;
26
+ --bt-px: 14px;
27
+ --bt-gap: 8px;
28
+
29
+ /* Per variant. */
30
+ --bt-fill: var(--bt-accent);
31
+ --bt-fill-hover: color-mix(in oklab, var(--bt-accent), var(--bt-fg) 12%);
32
+ --bt-ink: var(--bt-accent-fg);
33
+ --bt-line: var(--bt-accent);
34
+
35
+ color-scheme: inherit;
36
+ box-sizing: border-box;
37
+ position: relative;
38
+ display: inline-grid;
39
+ place-items: center;
40
+ height: var(--bt-height);
41
+ padding: 0 var(--bt-px);
42
+ border: 1px solid var(--bt-line);
43
+ border-radius: var(--bt-radius);
44
+ background: var(--bt-fill);
45
+ color: var(--bt-ink);
46
+ font: inherit;
47
+ font-size: var(--bt-font-size);
48
+ font-weight: 500;
49
+ line-height: 1;
50
+ white-space: nowrap;
51
+ text-decoration: none;
52
+ vertical-align: middle;
53
+ cursor: pointer;
54
+ user-select: none;
55
+ -webkit-tap-highlight-color: transparent;
56
+ transition:
57
+ background-color 120ms ease,
58
+ border-color 120ms ease,
59
+ color 120ms ease,
60
+ scale 120ms ease;
61
+ }
62
+
63
+ :where(.dark, [data-theme="dark"]) .bt-root {
64
+ color-scheme: dark;
65
+ }
66
+ :where(.light, [data-theme="light"]) .bt-root {
67
+ color-scheme: light;
68
+ }
69
+
70
+ :where(.bt-root) *,
71
+ :where(.bt-root) *::before,
72
+ :where(.bt-root) *::after {
73
+ box-sizing: border-box;
74
+ }
75
+
76
+ /* ------------------------------------------------------------ variants */
77
+
78
+ .bt-root[data-variant="secondary"] {
79
+ --bt-fill: var(--bt-subtle);
80
+ --bt-fill-hover: color-mix(in oklab, var(--bt-subtle), var(--bt-fg) 8%);
81
+ --bt-ink: var(--bt-fg);
82
+ --bt-line: transparent;
83
+ }
84
+ .bt-root[data-variant="outline"] {
85
+ --bt-fill: var(--bt-bg);
86
+ --bt-fill-hover: var(--bt-hover);
87
+ --bt-ink: var(--bt-fg);
88
+ --bt-line: var(--bt-border);
89
+ }
90
+ .bt-root[data-variant="ghost"] {
91
+ --bt-fill: transparent;
92
+ --bt-fill-hover: var(--bt-hover);
93
+ --bt-ink: var(--bt-fg);
94
+ --bt-line: transparent;
95
+ }
96
+ .bt-root[data-variant="danger"] {
97
+ --bt-fill: var(--bt-danger);
98
+ --bt-fill-hover: color-mix(in oklab, var(--bt-danger), var(--bt-fg) 12%);
99
+ --bt-ink: var(--bt-danger-fg);
100
+ --bt-line: var(--bt-danger);
101
+ }
102
+ .bt-root[data-variant="link"] {
103
+ --bt-fill: transparent;
104
+ --bt-fill-hover: transparent;
105
+ --bt-ink: var(--bt-accent);
106
+ --bt-line: transparent;
107
+ --bt-px: 0px;
108
+ height: auto;
109
+ text-underline-offset: 3px;
110
+ }
111
+ .bt-root[data-variant="link"]:hover:not([aria-disabled="true"], :disabled) {
112
+ text-decoration: underline;
113
+ }
114
+
115
+ /* --------------------------------------------------------------- sizes */
116
+
117
+ .bt-root[data-size="sm"] {
118
+ --bt-height: 30px;
119
+ --bt-px: 10px;
120
+ --bt-gap: 6px;
121
+ --bt-font-size: 12px;
122
+ }
123
+ .bt-root[data-size="lg"] {
124
+ --bt-height: 44px;
125
+ --bt-px: 18px;
126
+ --bt-gap: 10px;
127
+ --bt-font-size: 14px;
128
+ }
129
+
130
+ /* -------------------------------------------------------------- states */
131
+
132
+ .bt-root:hover:not([aria-disabled="true"], :disabled) {
133
+ background: var(--bt-fill-hover);
134
+ }
135
+ .bt-root:active:not([aria-disabled="true"], :disabled) {
136
+ scale: 0.98;
137
+ }
138
+ .bt-root:focus-visible {
139
+ outline: 2px solid var(--bt-focus);
140
+ outline-offset: 2px;
141
+ }
142
+ .bt-root:disabled,
143
+ .bt-root[aria-disabled="true"]:not([data-busy]) {
144
+ opacity: 0.5;
145
+ cursor: not-allowed;
146
+ }
147
+ .bt-root[data-busy] {
148
+ cursor: progress;
149
+ }
150
+
151
+ .bt-root[data-icon-only] {
152
+ width: var(--bt-height);
153
+ padding: 0;
154
+ }
155
+ .bt-root[data-full-width] {
156
+ width: 100%;
157
+ }
158
+
159
+ /* ------------------------------------------------------------- content */
160
+
161
+ .bt-content,
162
+ .bt-spinner {
163
+ grid-area: 1 / 1;
164
+ display: inline-flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ gap: var(--bt-gap);
168
+ min-width: 0;
169
+ }
170
+ /* The label keeps its space while the spinner sits on top… */
171
+ .bt-content[data-hidden] {
172
+ visibility: hidden;
173
+ }
174
+ /* …unless loading text replaces it. */
175
+ .bt-root[data-loading-text] .bt-content {
176
+ display: none;
177
+ }
178
+
179
+ .bt-icon {
180
+ display: inline-flex;
181
+ flex: none;
182
+ }
183
+ .bt-icon > svg {
184
+ width: 1.15em;
185
+ height: 1.15em;
186
+ }
187
+
188
+ .bt-spinner-ring {
189
+ width: 1.15em;
190
+ height: 1.15em;
191
+ fill: none;
192
+ stroke: currentColor;
193
+ stroke-width: 2.5;
194
+ stroke-linecap: round;
195
+ stroke-dasharray: 30 100;
196
+ animation: bt-rotate 0.8s linear infinite;
197
+ }
198
+ @keyframes bt-rotate {
199
+ to {
200
+ transform: rotate(360deg);
201
+ }
202
+ }
203
+
204
+ .bt-sr-only {
205
+ position: absolute;
206
+ width: 1px;
207
+ height: 1px;
208
+ margin: -1px;
209
+ padding: 0;
210
+ overflow: hidden;
211
+ clip-path: inset(50%);
212
+ white-space: nowrap;
213
+ border: 0;
214
+ }
215
+
216
+ @media (prefers-reduced-motion: reduce) {
217
+ .bt-root {
218
+ transition: none;
219
+ }
220
+ .bt-root:active:not([aria-disabled="true"], :disabled) {
221
+ scale: none;
222
+ }
223
+ .bt-spinner-ring {
224
+ animation-duration: 2.4s;
225
+ }
226
+ }
227
+
228
+ @media (forced-colors: active) {
229
+ .bt-root {
230
+ border-color: ButtonText;
231
+ }
232
+ }
233
+ }
@@ -0,0 +1,36 @@
1
+ # Checkbox and CheckboxGroup
2
+
3
+ Checkboxes for React 19, styled to match the rest of the library. The box is a
4
+ real `<input type="checkbox">`, so forms, form libraries and assistive
5
+ technology work as they do natively. No runtime dependencies besides React.
6
+
7
+ ```ts
8
+ import { Checkbox, CheckboxGroup } from "@/components/checkbox";
9
+ import "@/components/checkbox/styles.css";
10
+ ```
11
+
12
+ ```tsx
13
+ <Checkbox label="Remember me" name="remember" checked={remember} onCheckedChange={setRemember} />
14
+
15
+ <CheckboxGroup
16
+ label="Notify me by"
17
+ name="channels"
18
+ options={[{ value: "email", label: "Email" }, { value: "sms", label: "SMS" }]}
19
+ value={channels}
20
+ onValueChange={setChannels}
21
+ selectAll
22
+ />
23
+ ```
24
+
25
+ **Checkbox:** `checked` / `defaultChecked` (`true`, `false`, `"indeterminate"`),
26
+ `onCheckedChange`, `value`, `label`, `description`, `error`, `size`, `classNames`
27
+ (`root` `control` `input` `label` `description` `error`), `ref` (the `<input>`), and
28
+ every `<input>` attribute.
29
+
30
+ **CheckboxGroup:** `value` / `defaultValue` / `onValueChange` (string arrays),
31
+ `options` or `<Checkbox value>` children, `label` (legend), `description`,
32
+ `error`, `required`, `disabled`, `name`, `size`, `orientation`, `selectAll`,
33
+ `classNames` (`root` `legend` `description` `items` `error`), `localeText`.
34
+
35
+ `toggleValue`, `groupState` and `toggleAll` are exported from the
36
+ framework-free `core`.
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { groupState, toggleAll, toggleValue } from "../core/group";
3
+
4
+ const options = [
5
+ { value: "email" },
6
+ { value: "sms" },
7
+ { value: "push", disabled: true },
8
+ ];
9
+
10
+ describe("toggling one value", () => {
11
+ it("adds and removes without duplicates", () => {
12
+ expect(toggleValue(["email"], "sms", true)).toEqual(["email", "sms"]);
13
+ expect(toggleValue(["email"], "email", true)).toEqual(["email"]);
14
+ expect(toggleValue(["email", "sms"], "email", false)).toEqual(["sms"]);
15
+ expect(toggleValue([], "email", false)).toEqual([]);
16
+ });
17
+ });
18
+
19
+ describe("select all", () => {
20
+ it("reports none, some or all", () => {
21
+ expect(groupState([], options)).toBe(false);
22
+ expect(groupState(["sms"], options)).toBe("indeterminate");
23
+ expect(groupState(["email", "sms", "push"], options)).toBe(true);
24
+ expect(groupState(["email"], [])).toBe(false);
25
+ });
26
+
27
+ it("selects every enabled option, leaving disabled ones alone", () => {
28
+ expect(toggleAll(["sms"], options)).toEqual(["sms", "email"]);
29
+ expect(toggleAll([], options)).toEqual(["email", "sms"]);
30
+ });
31
+
32
+ it("clears enabled options once they are all selected", () => {
33
+ expect(toggleAll(["email", "sms"], options)).toEqual([]);
34
+ expect(toggleAll(["push", "email", "sms"], options)).toEqual(["push"]);
35
+ });
36
+
37
+ it("keeps values that aren't options", () => {
38
+ expect(toggleAll(["legacy"], options)).toEqual(["legacy", "email", "sms"]);
39
+ });
40
+ });
@@ -0,0 +1,30 @@
1
+ import type { CheckedState } from "./types";
2
+
3
+ /** Adds or removes one value, keeping the order values were selected in. */
4
+ export function toggleValue(values: readonly string[], value: string, checked: boolean): string[] {
5
+ const has = values.includes(value);
6
+ if (checked) return has ? [...values] : [...values, value];
7
+ return values.filter((item) => item !== value);
8
+ }
9
+
10
+ /** A "select all" box's state for the options in a group. */
11
+ export function groupState(values: readonly string[], options: readonly { value: string }[]): CheckedState {
12
+ if (options.length === 0) return false;
13
+ const selected = options.filter((option) => values.includes(option.value)).length;
14
+ if (selected === 0) return false;
15
+ return selected === options.length ? true : "indeterminate";
16
+ }
17
+
18
+ /**
19
+ * What "select all" does: when every enabled option is already selected, clear
20
+ * them; otherwise select them all. Disabled options keep their state either way.
21
+ */
22
+ export function toggleAll(
23
+ values: readonly string[],
24
+ options: readonly { value: string; disabled?: boolean }[],
25
+ ): string[] {
26
+ const enabled = options.filter((option) => !option.disabled).map((option) => option.value);
27
+ const allOn = enabled.every((value) => values.includes(value));
28
+ if (allOn) return values.filter((value) => !enabled.includes(value));
29
+ return [...values, ...enabled.filter((value) => !values.includes(value))];
30
+ }
@@ -0,0 +1,3 @@
1
+ // Framework-free: selection rules for checkbox groups.
2
+ export { groupState, toggleAll, toggleValue } from "./group";
3
+ export type * from "./types";
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type CheckboxSize = "sm" | "md" | "lg";
4
+
5
+ /** `true`, `false`, or partly checked. */
6
+ export type CheckedState = boolean | "indeterminate";
7
+
8
+ export interface CheckboxOption {
9
+ value: string;
10
+ label: ReactNode;
11
+ description?: ReactNode;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export interface CheckboxLocaleText {
16
+ selectAll: string;
17
+ }
@@ -0,0 +1,9 @@
1
+ export { Checkbox } from "./react/Checkbox";
2
+ export type { CheckboxProps } from "./react/Checkbox";
3
+ export { CheckboxGroup } from "./react/CheckboxGroup";
4
+ export type { CheckboxGroupProps } from "./react/CheckboxGroup";
5
+ export { CheckboxGroupContext } from "./react/context";
6
+ export type { CheckboxGroupContextValue } from "./react/context";
7
+ export { defaultCheckboxText } from "./react/locale";
8
+ export type { CheckboxGroupSlot, CheckboxSlot } from "./react/props";
9
+ export * from "./core";