gbs-add-block 1.2.14 → 1.2.16

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 (169) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +219 -21
  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 +147 -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/combobox/README.md +3 -3
  37. package/source/beta-components/combobox/react/MultiSelect.tsx +265 -263
  38. package/source/beta-components/combobox/react/Popover.tsx +38 -70
  39. package/source/beta-components/combobox/react/Select.tsx +200 -198
  40. package/source/beta-components/combobox/react/icons.tsx +6 -29
  41. package/source/beta-components/combobox/react/props.ts +4 -2
  42. package/source/beta-components/combobox/styles.css +18 -18
  43. package/source/beta-components/data-grid/react/Popover.tsx +35 -0
  44. package/source/beta-components/{datagrid → data-grid}/react/context.ts +2 -2
  45. package/source/beta-components/{datagrid → data-grid}/react/icons.tsx +8 -35
  46. package/source/beta-components/{datagrid → data-grid}/styles.css +24 -24
  47. package/source/beta-components/{datepicker → date-picker}/README.md +3 -3
  48. package/source/beta-components/{datepicker → date-picker}/react/DatePicker.tsx +249 -249
  49. package/source/beta-components/{datepicker → date-picker}/react/DateRangePicker.tsx +291 -291
  50. package/source/beta-components/date-picker/react/Popover.tsx +45 -0
  51. package/source/beta-components/date-picker/react/icons.tsx +15 -0
  52. package/source/beta-components/{datepicker → date-picker}/react/props.ts +2 -2
  53. package/source/beta-components/{datepicker → date-picker}/styles.css +18 -18
  54. package/source/beta-components/dialog/react/icons.tsx +16 -47
  55. package/source/beta-components/dialog/react/props.ts +2 -2
  56. package/source/beta-components/dialog/styles.css +21 -21
  57. package/source/beta-components/{fileuploader → file-uploader}/react/FileUploader.tsx +511 -515
  58. package/source/beta-components/{fileuploader → file-uploader}/react/icons.tsx +7 -27
  59. package/source/beta-components/{fileuploader → file-uploader}/react/props.ts +2 -2
  60. package/source/beta-components/{fileuploader → file-uploader}/styles.css +17 -17
  61. package/source/beta-components/input/core/count.ts +3 -12
  62. package/source/beta-components/input/react/Input.tsx +219 -219
  63. package/source/beta-components/input/react/OtpInput.tsx +256 -256
  64. package/source/beta-components/input/react/icons.tsx +3 -21
  65. package/source/beta-components/input/react/props.ts +2 -2
  66. package/source/beta-components/input/styles.css +15 -15
  67. package/source/beta-components/modal/react/Modal.tsx +242 -238
  68. package/source/beta-components/modal/react/icons.tsx +1 -19
  69. package/source/beta-components/modal/react/props.ts +2 -2
  70. package/source/beta-components/modal/styles.css +17 -13
  71. package/source/beta-components/shared/README.md +54 -0
  72. package/source/beta-components/shared/__tests__/boundaries.test.ts +95 -0
  73. package/source/beta-components/shared/__tests__/core.test.ts +55 -0
  74. package/source/beta-components/shared/core/cx.ts +9 -0
  75. package/source/beta-components/shared/core/field.ts +35 -0
  76. package/source/beta-components/shared/core/text.ts +14 -0
  77. package/source/beta-components/shared/index.ts +7 -0
  78. package/source/beta-components/shared/react/Popover.tsx +147 -0
  79. package/source/beta-components/shared/react/icons.tsx +74 -0
  80. package/source/beta-components/shared/react/useControllableState.ts +42 -0
  81. package/source/beta-components/shared/version.json +4 -0
  82. package/source/beta-components/shared/version.ts +6 -0
  83. package/source/beta-components/spinner/README.md +29 -0
  84. package/source/beta-components/spinner/__tests__/core.test.ts +48 -0
  85. package/source/beta-components/spinner/core/index.ts +3 -0
  86. package/source/beta-components/spinner/core/types.ts +28 -0
  87. package/source/beta-components/spinner/core/visibility.ts +34 -0
  88. package/source/beta-components/spinner/index.ts +6 -0
  89. package/source/beta-components/spinner/react/Spinner.tsx +96 -0
  90. package/source/beta-components/spinner/react/locale.ts +5 -0
  91. package/source/beta-components/spinner/react/props.ts +4 -0
  92. package/source/beta-components/spinner/react/useDelayedLoading.ts +39 -0
  93. package/source/beta-components/spinner/styles.css +167 -0
  94. package/source/beta-components/tabs/README.md +32 -0
  95. package/source/beta-components/tabs/__tests__/core.test.ts +51 -0
  96. package/source/beta-components/tabs/core/index.ts +3 -0
  97. package/source/beta-components/tabs/core/navigation.ts +54 -0
  98. package/source/beta-components/tabs/core/types.ts +16 -0
  99. package/source/beta-components/tabs/index.ts +5 -0
  100. package/source/beta-components/tabs/react/Tabs.tsx +258 -0
  101. package/source/beta-components/tabs/react/context.ts +21 -0
  102. package/source/beta-components/tabs/styles.css +257 -0
  103. package/source/beta-components/textarea/core/count.ts +3 -12
  104. package/source/beta-components/textarea/react/Textarea.tsx +208 -209
  105. package/source/beta-components/textarea/react/props.ts +2 -2
  106. package/source/beta-components/textarea/styles.css +12 -12
  107. package/source/beta-components/toaster/react/icons.tsx +19 -52
  108. package/source/beta-components/toaster/react/props.ts +2 -2
  109. package/source/beta-components/toaster/styles.css +18 -18
  110. package/source/beta-components/datagrid/react/Popover.tsx +0 -79
  111. package/source/beta-components/datepicker/react/Popover.tsx +0 -87
  112. package/source/beta-components/datepicker/react/icons.tsx +0 -32
  113. /package/source/beta-components/{datagrid → data-grid}/README.md +0 -0
  114. /package/source/beta-components/{datagrid → data-grid}/__tests__/core.test.ts +0 -0
  115. /package/source/beta-components/{datagrid → data-grid}/__tests__/export.test.ts +0 -0
  116. /package/source/beta-components/{datagrid → data-grid}/core/columnHelper.ts +0 -0
  117. /package/source/beta-components/{datagrid → data-grid}/core/columns.ts +0 -0
  118. /package/source/beta-components/{datagrid → data-grid}/core/filtering.ts +0 -0
  119. /package/source/beta-components/{datagrid → data-grid}/core/grid.ts +0 -0
  120. /package/source/beta-components/{datagrid → data-grid}/core/index.ts +0 -0
  121. /package/source/beta-components/{datagrid → data-grid}/core/rows.ts +0 -0
  122. /package/source/beta-components/{datagrid → data-grid}/core/sorting.ts +0 -0
  123. /package/source/beta-components/{datagrid → data-grid}/core/state.ts +0 -0
  124. /package/source/beta-components/{datagrid → data-grid}/core/store.ts +0 -0
  125. /package/source/beta-components/{datagrid → data-grid}/core/types.ts +0 -0
  126. /package/source/beta-components/{datagrid → data-grid}/core/values.ts +0 -0
  127. /package/source/beta-components/{datagrid → data-grid}/core/virtual.ts +0 -0
  128. /package/source/beta-components/{datagrid → data-grid}/export/csv.ts +0 -0
  129. /package/source/beta-components/{datagrid → data-grid}/export/download.ts +0 -0
  130. /package/source/beta-components/{datagrid → data-grid}/export/pdf.ts +0 -0
  131. /package/source/beta-components/{datagrid → data-grid}/export/table.ts +0 -0
  132. /package/source/beta-components/{datagrid → data-grid}/export/xlsx.ts +0 -0
  133. /package/source/beta-components/{datagrid → data-grid}/export/zip.ts +0 -0
  134. /package/source/beta-components/{datagrid → data-grid}/index.ts +0 -0
  135. /package/source/beta-components/{datagrid → data-grid}/react/Cell.tsx +0 -0
  136. /package/source/beta-components/{datagrid → data-grid}/react/ColumnMenu.tsx +0 -0
  137. /package/source/beta-components/{datagrid → data-grid}/react/DataGrid.tsx +0 -0
  138. /package/source/beta-components/{datagrid → data-grid}/react/FilterForm.tsx +0 -0
  139. /package/source/beta-components/{datagrid → data-grid}/react/HeaderRow.tsx +0 -0
  140. /package/source/beta-components/{datagrid → data-grid}/react/Pagination.tsx +0 -0
  141. /package/source/beta-components/{datagrid → data-grid}/react/Row.tsx +0 -0
  142. /package/source/beta-components/{datagrid → data-grid}/react/Toolbar.tsx +0 -0
  143. /package/source/beta-components/{datagrid → data-grid}/react/Viewport.tsx +0 -0
  144. /package/source/beta-components/{datagrid → data-grid}/react/hooks.ts +0 -0
  145. /package/source/beta-components/{datagrid → data-grid}/react/keyboard.ts +0 -0
  146. /package/source/beta-components/{datagrid → data-grid}/react/locale.ts +0 -0
  147. /package/source/beta-components/{datepicker → date-picker}/__tests__/core.test.ts +0 -0
  148. /package/source/beta-components/{datepicker → date-picker}/core/calendar.ts +0 -0
  149. /package/source/beta-components/{datepicker → date-picker}/core/format.ts +0 -0
  150. /package/source/beta-components/{datepicker → date-picker}/core/index.ts +0 -0
  151. /package/source/beta-components/{datepicker → date-picker}/core/types.ts +0 -0
  152. /package/source/beta-components/{datepicker → date-picker}/index.ts +0 -0
  153. /package/source/beta-components/{datepicker → date-picker}/react/Calendar.tsx +0 -0
  154. /package/source/beta-components/{datepicker → date-picker}/react/CalendarPanel.tsx +0 -0
  155. /package/source/beta-components/{datepicker → date-picker}/react/MonthYearPanels.tsx +0 -0
  156. /package/source/beta-components/{datepicker → date-picker}/react/locale.ts +0 -0
  157. /package/source/beta-components/{datepicker → date-picker}/react/useDatePicker.ts +0 -0
  158. /package/source/beta-components/{fileuploader → file-uploader}/README.md +0 -0
  159. /package/source/beta-components/{fileuploader → file-uploader}/__tests__/core.test.ts +0 -0
  160. /package/source/beta-components/{fileuploader → file-uploader}/core/format.ts +0 -0
  161. /package/source/beta-components/{fileuploader → file-uploader}/core/index.ts +0 -0
  162. /package/source/beta-components/{fileuploader → file-uploader}/core/store.ts +0 -0
  163. /package/source/beta-components/{fileuploader → file-uploader}/core/transport.ts +0 -0
  164. /package/source/beta-components/{fileuploader → file-uploader}/core/types.ts +0 -0
  165. /package/source/beta-components/{fileuploader → file-uploader}/core/validate.ts +0 -0
  166. /package/source/beta-components/{fileuploader → file-uploader}/index.ts +0 -0
  167. /package/source/beta-components/{fileuploader → file-uploader}/react/FileRow.tsx +0 -0
  168. /package/source/beta-components/{fileuploader → file-uploader}/react/locale.ts +0 -0
  169. /package/source/beta-components/{fileuploader → file-uploader}/react/useFileUploader.ts +0 -0
@@ -0,0 +1,177 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type HTMLAttributes,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { collapseItems, toBreadcrumbJsonLd } from "../core/trail";
13
+ import type { BreadcrumbItem, BreadcrumbLocaleText } from "../core/types";
14
+ import { defaultBreadcrumbText } from "./locale";
15
+ import { cx, type BreadcrumbLinkProps, type BreadcrumbSlot } from "./props";
16
+
17
+ export interface BreadcrumbProps extends Omit<HTMLAttributes<HTMLElement>, "children"> {
18
+ /** From the top level to the current page. The last item is the current page. */
19
+ items: readonly BreadcrumbItem[];
20
+ /** Between crumbs. Default a chevron. */
21
+ separator?: ReactNode;
22
+ /** Collapse the middle of the trail when it has more crumbs than this. */
23
+ maxItems?: number;
24
+ /** Crumbs kept before the collapsed part. Default 1. */
25
+ itemsBeforeCollapse?: number;
26
+ /** Crumbs kept after the collapsed part. Default 1. */
27
+ itemsAfterCollapse?: number;
28
+ /** Draw links with your router, e.g. `(props) => <Link {...props} />`. */
29
+ renderLink?(props: BreadcrumbLinkProps): ReactElement;
30
+ /**
31
+ * Add schema.org `BreadcrumbList` JSON-LD for search engines. Pass
32
+ * `{ baseUrl }` so relative links become absolute, as search engines require.
33
+ */
34
+ structuredData?: boolean | { baseUrl: string };
35
+ size?: "sm" | "md" | "lg";
36
+ classNames?: Partial<Record<BreadcrumbSlot, string>>;
37
+ localeText?: Partial<BreadcrumbLocaleText>;
38
+ }
39
+
40
+ const Chevron = () => (
41
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" focusable="false">
42
+ <path d="m9 18 6-6-6-6" />
43
+ </svg>
44
+ );
45
+
46
+ /** Where the current page sits in the site's hierarchy. */
47
+ export function Breadcrumb(props: BreadcrumbProps) {
48
+ const {
49
+ items,
50
+ separator,
51
+ maxItems,
52
+ itemsBeforeCollapse = 1,
53
+ itemsAfterCollapse = 1,
54
+ renderLink,
55
+ structuredData = false,
56
+ size = "md",
57
+ className,
58
+ classNames,
59
+ localeText,
60
+ "aria-label": ariaLabel,
61
+ ...rest
62
+ } = props;
63
+
64
+ const text = useMemo(() => ({ ...defaultBreadcrumbText, ...localeText }), [localeText]);
65
+ const listRef = useRef<HTMLOListElement>(null);
66
+ const [expanded, setExpanded] = useState(false);
67
+ const [revealFrom, setRevealFrom] = useState<number | null>(null);
68
+
69
+ // After expanding, move focus to the first crumb that was hidden, not to <body>.
70
+ useEffect(() => {
71
+ if (revealFrom === null) return;
72
+ listRef.current?.querySelector<HTMLElement>(`[data-index="${revealFrom}"] :is(a, button, [tabindex])`)?.focus();
73
+ }, [revealFrom]);
74
+
75
+ const slots = expanded
76
+ ? collapseItems(items.length)
77
+ : collapseItems(items.length, maxItems, itemsBeforeCollapse, itemsAfterCollapse);
78
+ const lastIndex = items.length - 1;
79
+
80
+ const renderItem = (item: BreadcrumbItem, index: number) => {
81
+ const content = (
82
+ <>
83
+ {item.icon && (
84
+ <span className="bc-icon" aria-hidden="true">
85
+ {item.icon}
86
+ </span>
87
+ )}
88
+ <span className="bc-text" title={typeof item.label === "string" ? item.label : undefined}>
89
+ {item.label}
90
+ </span>
91
+ </>
92
+ );
93
+
94
+ if (index === lastIndex || (!item.href && !item.onClick)) {
95
+ return (
96
+ <span
97
+ className={cx("bc-current", classNames?.current)}
98
+ aria-current={index === lastIndex ? "page" : undefined}
99
+ >
100
+ {content}
101
+ </span>
102
+ );
103
+ }
104
+ if (item.href) {
105
+ const linkProps: BreadcrumbLinkProps = {
106
+ href: item.href,
107
+ className: cx("bc-link", classNames?.link),
108
+ children: content,
109
+ onClick: item.onClick ? () => item.onClick?.() : undefined,
110
+ };
111
+ return renderLink ? renderLink(linkProps) : <a {...linkProps} />;
112
+ }
113
+ return (
114
+ <button type="button" className={cx("bc-link", classNames?.link)} onClick={item.onClick}>
115
+ {content}
116
+ </button>
117
+ );
118
+ };
119
+
120
+ const jsonLd = structuredData
121
+ ? toBreadcrumbJsonLd(
122
+ items.map((item) => ({
123
+ name: item.name ?? (typeof item.label === "string" ? item.label : ""),
124
+ href: item.href,
125
+ })),
126
+ typeof structuredData === "object" ? structuredData.baseUrl : undefined,
127
+ )
128
+ : null;
129
+
130
+ return (
131
+ <nav
132
+ {...rest}
133
+ aria-label={ariaLabel ?? text.label}
134
+ className={cx("bc-root", classNames?.root, className)}
135
+ data-size={size}
136
+ >
137
+ <ol ref={listRef} className={cx("bc-list", classNames?.list)}>
138
+ {slots.map((slot, position) => (
139
+ <li
140
+ key={slot.kind === "item" ? slot.index : "ellipsis"}
141
+ className={cx("bc-item", classNames?.item)}
142
+ data-index={slot.kind === "item" ? slot.index : undefined}
143
+ >
144
+ {position > 0 && (
145
+ <span className={cx("bc-separator", classNames?.separator)} aria-hidden="true">
146
+ {separator ?? <Chevron />}
147
+ </span>
148
+ )}
149
+ {slot.kind === "item" ? (
150
+ renderItem(items[slot.index], slot.index)
151
+ ) : (
152
+ <button
153
+ type="button"
154
+ className={cx("bc-ellipsis", classNames?.ellipsis)}
155
+ aria-label={text.showMore(String(slot.hidden.length))}
156
+ title={text.showMore(String(slot.hidden.length))}
157
+ onClick={() => {
158
+ setExpanded(true);
159
+ setRevealFrom(slot.hidden[0]);
160
+ }}
161
+ >
162
+ …
163
+ </button>
164
+ )}
165
+ </li>
166
+ ))}
167
+ </ol>
168
+ {jsonLd && (
169
+ <script
170
+ type="application/ld+json"
171
+ // `<` is escaped so a crumb's name can never close the script tag.
172
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
173
+ />
174
+ )}
175
+ </nav>
176
+ );
177
+ }
@@ -0,0 +1,6 @@
1
+ import type { BreadcrumbLocaleText } from "../core/types";
2
+
3
+ export const defaultBreadcrumbText: BreadcrumbLocaleText = {
4
+ label: "Breadcrumb",
5
+ showMore: (count) => `Show ${count} more`,
6
+ };
@@ -0,0 +1,14 @@
1
+ import type { MouseEventHandler, ReactNode } from "react";
2
+
3
+ export type BreadcrumbSlot = "root" | "list" | "item" | "link" | "current" | "separator" | "ellipsis";
4
+
5
+ /** Handed to `renderLink`, to draw a crumb with your router's link component. */
6
+ export interface BreadcrumbLinkProps {
7
+ href: string;
8
+ className: string;
9
+ children: ReactNode;
10
+ onClick?: MouseEventHandler<HTMLAnchorElement>;
11
+ }
12
+
13
+ // Shared with every other component, so class handling cannot drift.
14
+ export { cx } from "../../shared/core/cx";
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Breadcrumb styles.
3
+ *
4
+ * Tokens read the shared --gbs-* variables when they are set on an ancestor
5
+ * (set them on :root to theme every component at once) and fall back to the
6
+ * same default palette 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
+ .bc-root {
13
+ --bc-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
14
+ --bc-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
15
+ --bc-hover: var(--gbs-hover, var(--dg-hover, light-dark(#f4f4f5, #1f1f23)));
16
+ --bc-focus: var(--gbs-focus, var(--dg-focus, light-dark(#2563eb, #60a5fa)));
17
+ --bc-font-size: var(--gbs-font-size, var(--dg-font-size, 13px));
18
+ /* Long labels are cut with an ellipsis; the full text is in the tooltip. */
19
+ --bc-max-label: 24ch;
20
+
21
+ color-scheme: inherit;
22
+ font-size: var(--bc-font-size);
23
+ line-height: 1.4;
24
+ color: var(--bc-muted);
25
+ }
26
+
27
+ :where(.dark, [data-theme="dark"]) .bc-root {
28
+ color-scheme: dark;
29
+ }
30
+ :where(.light, [data-theme="light"]) .bc-root {
31
+ color-scheme: light;
32
+ }
33
+
34
+ :where(.bc-root) *,
35
+ :where(.bc-root) *::before,
36
+ :where(.bc-root) *::after {
37
+ box-sizing: border-box;
38
+ }
39
+
40
+ .bc-root[data-size="sm"] {
41
+ --bc-font-size: 12px;
42
+ }
43
+ .bc-root[data-size="lg"] {
44
+ --bc-font-size: 14px;
45
+ }
46
+
47
+ .bc-list {
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ align-items: center;
51
+ gap: 4px 6px;
52
+ margin: 0;
53
+ padding: 0;
54
+ list-style: none;
55
+ }
56
+ .bc-item {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 6px;
60
+ min-width: 0;
61
+ }
62
+
63
+ .bc-link,
64
+ .bc-current {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 5px;
68
+ min-width: 0;
69
+ }
70
+ .bc-link {
71
+ margin: -2px -4px;
72
+ padding: 2px 4px;
73
+ border: 0;
74
+ border-radius: 4px;
75
+ background: none;
76
+ color: inherit;
77
+ font: inherit;
78
+ text-decoration: none;
79
+ cursor: pointer;
80
+ }
81
+ .bc-link:hover {
82
+ color: var(--bc-fg);
83
+ text-decoration: underline;
84
+ text-underline-offset: 3px;
85
+ }
86
+ .bc-link:focus-visible,
87
+ .bc-ellipsis:focus-visible {
88
+ outline: 2px solid var(--bc-focus);
89
+ outline-offset: 1px;
90
+ }
91
+ .bc-current {
92
+ color: var(--bc-fg);
93
+ font-weight: 500;
94
+ }
95
+
96
+ .bc-text {
97
+ max-width: var(--bc-max-label);
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ white-space: nowrap;
101
+ }
102
+ .bc-icon {
103
+ display: inline-flex;
104
+ flex: none;
105
+ }
106
+ .bc-icon > svg {
107
+ width: 1.1em;
108
+ height: 1.1em;
109
+ }
110
+
111
+ .bc-separator {
112
+ display: inline-flex;
113
+ flex: none;
114
+ opacity: 0.7;
115
+ }
116
+ /* A chevron points the reading direction. */
117
+ .bc-separator > svg:dir(rtl) {
118
+ transform: scaleX(-1);
119
+ }
120
+
121
+ .bc-ellipsis {
122
+ display: inline-grid;
123
+ place-items: center;
124
+ min-width: 24px;
125
+ height: 22px;
126
+ padding: 0 4px;
127
+ border: 0;
128
+ border-radius: 4px;
129
+ background: transparent;
130
+ color: inherit;
131
+ font: inherit;
132
+ letter-spacing: 1px;
133
+ cursor: pointer;
134
+ }
135
+ .bc-ellipsis:hover {
136
+ background: var(--bc-hover);
137
+ color: var(--bc-fg);
138
+ }
139
+ }
@@ -0,0 +1,32 @@
1
+ # Button
2
+
3
+ A button for React 19 with variants, sizes, icons and a loading state that
4
+ manages itself, styled to match the rest of the library. No runtime
5
+ dependencies besides React.
6
+
7
+ ```ts
8
+ import { Button } from "@/components/button";
9
+ import "@/components/button/styles.css";
10
+ ```
11
+
12
+ ```tsx
13
+ <Button onClick={async () => await save()}>Save</Button> {/* spinner while the promise runs */}
14
+ <Button type="submit">Create</Button> {/* spinner while the form's Server Action runs */}
15
+ <Button variant="outline" leading={<PlusIcon />}>New invoice</Button>
16
+ <Button variant="ghost" icon={<TrashIcon />} aria-label="Delete" />
17
+ <Button render={(props) => <Link href="/billing" {...props} />}>Billing</Button>
18
+ ```
19
+
20
+ | Prop | Default | Description |
21
+ | --- | --- | --- |
22
+ | `variant` | `primary` | `primary`, `secondary`, `outline`, `ghost`, `danger`, `link`. |
23
+ | `size` | `md` | `sm` 30 · `md` 36 · `lg` 44 px. |
24
+ | `type` | `button` | Not `submit`, so buttons inside forms don't submit by accident. |
25
+ | `loading` / `loadingText` | `false` / — | Busy state; stays focusable and keeps its width. |
26
+ | `leading`, `trailing`, `icon` | — | Icons; `icon` alone makes an icon-only button (add `aria-label`). |
27
+ | `fullWidth` | `false` | Fill the container. |
28
+ | `onClick` | — | Return a promise to show loading until it settles. |
29
+ | `render` | — | Render another element (e.g. a router link) with the button's look. |
30
+ | `className`, `classNames`, `localeText`, `ref` | — | Slots: `root`, `content`, `spinner`, `icon`. |
31
+
32
+ Every other `<button>` attribute passes through.
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buttonState, isPromiseLike } from "../core/state";
3
+
4
+ describe("button state", () => {
5
+ it("is active by default", () => {
6
+ expect(buttonState({})).toEqual({ busy: false, inactive: false, nativeDisabled: false });
7
+ });
8
+
9
+ it("stays focusable while busy", () => {
10
+ expect(buttonState({ loading: true })).toEqual({ busy: true, inactive: true, nativeDisabled: false });
11
+ expect(buttonState({ pending: true })).toEqual({ busy: true, inactive: true, nativeDisabled: false });
12
+ });
13
+
14
+ it("uses native disabled, and shows no spinner, when disabled", () => {
15
+ expect(buttonState({ disabled: true, loading: true })).toEqual({
16
+ busy: false,
17
+ inactive: true,
18
+ nativeDisabled: true,
19
+ });
20
+ });
21
+ });
22
+
23
+ describe("promise detection", () => {
24
+ it("recognizes promises and thenables only", () => {
25
+ expect(isPromiseLike(Promise.resolve())).toBe(true);
26
+ expect(isPromiseLike({ then: () => {} })).toBe(true);
27
+ expect(isPromiseLike(undefined)).toBe(false);
28
+ expect(isPromiseLike({ then: 1 })).toBe(false);
29
+ expect(isPromiseLike(() => {})).toBe(false);
30
+ });
31
+ });
@@ -0,0 +1,3 @@
1
+ // Framework-free: the button's state rules.
2
+ export { buttonState, isPromiseLike } from "./state";
3
+ export type * from "./types";
@@ -0,0 +1,16 @@
1
+ import type { ButtonState, ButtonStateInput } from "./types";
2
+
3
+ /** An async click handler's return value: anything with a `then`. */
4
+ export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
5
+ return (
6
+ typeof value === "object" &&
7
+ value !== null &&
8
+ typeof (value as { then?: unknown }).then === "function"
9
+ );
10
+ }
11
+
12
+ /** How a button behaves for its combination of flags. */
13
+ export function buttonState({ disabled = false, loading = false, pending = false }: ButtonStateInput): ButtonState {
14
+ const busy = !disabled && (loading || pending);
15
+ return { busy, inactive: disabled || busy, nativeDisabled: disabled };
16
+ }
@@ -0,0 +1,28 @@
1
+ export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger" | "link";
2
+
3
+ export type ButtonSize = "sm" | "md" | "lg";
4
+
5
+ export interface ButtonStateInput {
6
+ disabled?: boolean;
7
+ /** Set by you. */
8
+ loading?: boolean;
9
+ /** Set by the button itself: an async `onClick` or a pending form action. */
10
+ pending?: boolean;
11
+ }
12
+
13
+ export interface ButtonState {
14
+ /** Show the spinner and `aria-busy`. */
15
+ busy: boolean;
16
+ /** Ignore activation. */
17
+ inactive: boolean;
18
+ /**
19
+ * Use the native `disabled` attribute. A busy button stays focusable and uses
20
+ * `aria-disabled` instead, so keyboard focus isn't thrown away mid-action.
21
+ */
22
+ nativeDisabled: boolean;
23
+ }
24
+
25
+ export interface ButtonLocaleText {
26
+ /** Announced while the button is busy. */
27
+ loading: string;
28
+ }
@@ -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
+ // Shared with every other component, so class handling cannot drift.
23
+ export { cx } from "../../shared/core/cx";