gbs-add-block 1.2.14 โ†’ 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 (55) hide show
  1. package/README.md +2 -2
  2. package/index.cjs +5 -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/spinner/README.md +29 -0
  37. package/source/beta-components/spinner/__tests__/core.test.ts +48 -0
  38. package/source/beta-components/spinner/core/index.ts +3 -0
  39. package/source/beta-components/spinner/core/types.ts +28 -0
  40. package/source/beta-components/spinner/core/visibility.ts +34 -0
  41. package/source/beta-components/spinner/index.ts +6 -0
  42. package/source/beta-components/spinner/react/Spinner.tsx +96 -0
  43. package/source/beta-components/spinner/react/locale.ts +5 -0
  44. package/source/beta-components/spinner/react/props.ts +4 -0
  45. package/source/beta-components/spinner/react/useDelayedLoading.ts +39 -0
  46. package/source/beta-components/spinner/styles.css +167 -0
  47. package/source/beta-components/tabs/README.md +32 -0
  48. package/source/beta-components/tabs/__tests__/core.test.ts +51 -0
  49. package/source/beta-components/tabs/core/index.ts +3 -0
  50. package/source/beta-components/tabs/core/navigation.ts +54 -0
  51. package/source/beta-components/tabs/core/types.ts +16 -0
  52. package/source/beta-components/tabs/index.ts +5 -0
  53. package/source/beta-components/tabs/react/Tabs.tsx +260 -0
  54. package/source/beta-components/tabs/react/context.ts +21 -0
  55. package/source/beta-components/tabs/styles.css +257 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.14)
1
+ # GBS Building Blocks 2.0 (v1.2.15)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
@@ -10,7 +10,7 @@ For detailed documentation on usage and props, Please visit: [Building Block Doc
10
10
 
11
11
  New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
12
12
 
13
- ## What's New ๐ŸŽ‰ (Ver 1.2.14)
13
+ ## What's New ๐ŸŽ‰ (Ver 1.2.15)
14
14
 
15
15
  - Update Candidate for next major change 2.0.0
16
16
 
package/index.cjs CHANGED
@@ -45,6 +45,11 @@ const CONFIG = {
45
45
  "Input",
46
46
  "Modal",
47
47
  "Textarea",
48
+ "Button",
49
+ "Breadcrumb",
50
+ "Checkbox",
51
+ "Tabs",
52
+ "Spinner",
48
53
  ],
49
54
  // Define component dependencies
50
55
  dependencies: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.14",
3
+ "version": "1.2.15",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,34 @@
1
+ # Breadcrumb
2
+
3
+ A breadcrumb trail for React 19, styled to match the rest of the library. No
4
+ runtime dependencies besides React.
5
+
6
+ ```ts
7
+ import { Breadcrumb } from "@/components/breadcrumb";
8
+ import "@/components/breadcrumb/styles.css";
9
+ ```
10
+
11
+ ```tsx
12
+ <Breadcrumb
13
+ items={[
14
+ { label: "Home", href: "/" },
15
+ { label: "Invoices", href: "/invoices" },
16
+ { label: "INV-2026-0042" },
17
+ ]}
18
+ maxItems={4}
19
+ renderLink={(props) => <Link {...props} />}
20
+ structuredData={{ baseUrl: "https://app.example.com" }}
21
+ />
22
+ ```
23
+
24
+ | Prop | Default | Description |
25
+ | --- | --- | --- |
26
+ | `items` | required | `{ label, href?, icon?, name?, onClick? }`, top level first; the last is the current page. |
27
+ | `separator` | chevron | Between crumbs. |
28
+ | `maxItems`, `itemsBeforeCollapse`, `itemsAfterCollapse` | โ€”, `1`, `1` | Collapse the middle of long trails behind an ellipsis. |
29
+ | `renderLink` | `<a>` | Draw links with your router. |
30
+ | `structuredData` | `false` | Adds schema.org `BreadcrumbList` JSON-LD; pass `{ baseUrl }`. |
31
+ | `size`, `className`, `classNames`, `localeText` | `md` | Slots: `root` `list` `item` `link` `current` `separator` `ellipsis`. |
32
+
33
+ `collapseItems` and `toBreadcrumbJsonLd` are exported from the framework-free
34
+ `core`.
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collapseItems, toBreadcrumbJsonLd } from "../core/trail";
3
+
4
+ const shape = (slots: ReturnType<typeof collapseItems>) =>
5
+ slots.map((slot) => (slot.kind === "item" ? slot.index : `โ€ฆ${slot.hidden.join(",")}`));
6
+
7
+ describe("collapsing", () => {
8
+ it("leaves a short trail alone", () => {
9
+ expect(shape(collapseItems(3))).toEqual([0, 1, 2]);
10
+ expect(shape(collapseItems(4, 4))).toEqual([0, 1, 2, 3]);
11
+ });
12
+
13
+ it("keeps the first and last crumbs around an ellipsis", () => {
14
+ expect(shape(collapseItems(6, 4))).toEqual([0, "โ€ฆ1,2,3,4", 5]);
15
+ });
16
+
17
+ it("keeps more on either side when asked", () => {
18
+ expect(shape(collapseItems(7, 4, 1, 2))).toEqual([0, "โ€ฆ1,2,3,4", 5, 6]);
19
+ expect(shape(collapseItems(7, 4, 2, 2))).toEqual([0, 1, "โ€ฆ2,3,4", 5, 6]);
20
+ expect(shape(collapseItems(7, 4, 0, 1))).toEqual(["โ€ฆ0,1,2,3,4,5", 6]);
21
+ });
22
+
23
+ it("doesn't collapse when the kept crumbs already cover the trail", () => {
24
+ expect(shape(collapseItems(4, 2, 2, 2))).toEqual([0, 1, 2, 3]);
25
+ });
26
+
27
+ it("always keeps the current page", () => {
28
+ expect(shape(collapseItems(5, 2, 1, 0))).toEqual([0, "โ€ฆ1,2,3", 4]);
29
+ });
30
+ });
31
+
32
+ describe("structured data", () => {
33
+ it("builds a schema.org BreadcrumbList with absolute links", () => {
34
+ const data = toBreadcrumbJsonLd(
35
+ [
36
+ { name: "Home", href: "/" },
37
+ { name: "Invoices", href: "/invoices" },
38
+ { name: "INV-042" },
39
+ ],
40
+ "https://app.example.com/billing/",
41
+ );
42
+ expect(data).toEqual({
43
+ "@context": "https://schema.org",
44
+ "@type": "BreadcrumbList",
45
+ itemListElement: [
46
+ { "@type": "ListItem", position: 1, name: "Home", item: "https://app.example.com/" },
47
+ { "@type": "ListItem", position: 2, name: "Invoices", item: "https://app.example.com/invoices" },
48
+ { "@type": "ListItem", position: 3, name: "INV-042" },
49
+ ],
50
+ });
51
+ });
52
+
53
+ it("keeps links as given without a base URL", () => {
54
+ expect(toBreadcrumbJsonLd([{ name: "Docs", href: "https://example.com/docs" }]).itemListElement[0].item).toBe(
55
+ "https://example.com/docs",
56
+ );
57
+ });
58
+ });
@@ -0,0 +1,4 @@
1
+ // Framework-free: trail collapsing and structured data.
2
+ export { collapseItems, toBreadcrumbJsonLd } from "./trail";
3
+ export type { JsonLdCrumb } from "./trail";
4
+ export type * from "./types";
@@ -0,0 +1,42 @@
1
+ import type { CrumbSlot } from "./types";
2
+
3
+ /**
4
+ * Collapses a long trail to its first and last crumbs with an ellipsis between,
5
+ * once it has more than `maxItems`. Returns the trail unchanged when it fits.
6
+ */
7
+ export function collapseItems(
8
+ count: number,
9
+ maxItems?: number,
10
+ itemsBefore = 1,
11
+ itemsAfter = 1,
12
+ ): CrumbSlot[] {
13
+ const before = Math.max(0, itemsBefore);
14
+ const after = Math.max(1, itemsAfter);
15
+ const all: CrumbSlot[] = Array.from({ length: count }, (_, index) => ({ kind: "item", index }));
16
+ if (maxItems === undefined || count <= maxItems || before + after >= count) return all;
17
+
18
+ const hidden = Array.from({ length: count - before - after }, (_, offset) => before + offset);
19
+ return [...all.slice(0, before), { kind: "ellipsis", hidden }, ...all.slice(count - after)];
20
+ }
21
+
22
+ export interface JsonLdCrumb {
23
+ name: string;
24
+ href?: string;
25
+ }
26
+
27
+ /**
28
+ * schema.org `BreadcrumbList` data, which search engines use to show the trail
29
+ * in results. Relative links are resolved against `baseUrl`.
30
+ */
31
+ export function toBreadcrumbJsonLd(items: readonly JsonLdCrumb[], baseUrl?: string) {
32
+ return {
33
+ "@context": "https://schema.org",
34
+ "@type": "BreadcrumbList",
35
+ itemListElement: items.map((item, index) => ({
36
+ "@type": "ListItem",
37
+ position: index + 1,
38
+ name: item.name,
39
+ ...(item.href ? { item: baseUrl ? new URL(item.href, baseUrl).href : item.href } : {}),
40
+ })),
41
+ };
42
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface BreadcrumbItem {
4
+ label: ReactNode;
5
+ /** Omit for the current page or a crumb that isn't a link. */
6
+ href?: string;
7
+ icon?: ReactNode;
8
+ /** Plain-text name, for structured data when `label` isn't a string. */
9
+ name?: string;
10
+ /** Called on click, e.g. for client-side navigation without an href. */
11
+ onClick?(): void;
12
+ }
13
+
14
+ /** One position in the rendered trail. */
15
+ export type CrumbSlot = { kind: "item"; index: number } | { kind: "ellipsis"; hidden: number[] };
16
+
17
+ export interface BreadcrumbLocaleText {
18
+ label: string;
19
+ showMore(count: string): string;
20
+ }
@@ -0,0 +1,5 @@
1
+ export { Breadcrumb } from "./react/Breadcrumb";
2
+ export type { BreadcrumbProps } from "./react/Breadcrumb";
3
+ export { defaultBreadcrumbText } from "./react/locale";
4
+ export type { BreadcrumbLinkProps, BreadcrumbSlot } from "./react/props";
5
+ export * from "./core";
@@ -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
+ export const cx = (...names: (string | false | null | undefined)[]) =>
14
+ names.filter(Boolean).join(" ");
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Breadcrumb 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
+ .bc-root {
13
+ --bc-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
14
+ --bc-muted: var(--dg-muted, light-dark(#71717a, #a1a1aa));
15
+ --bc-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
16
+ --bc-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
17
+ --bc-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";