@galyan/ui 0.0.1

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 (95) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/dist/index.cjs +2291 -0
  3. package/dist/index.css +2558 -0
  4. package/dist/index.d.cts +498 -0
  5. package/dist/index.d.ts +498 -0
  6. package/dist/index.js +2235 -0
  7. package/eslint.config.mjs +4 -0
  8. package/package.json +43 -0
  9. package/src/accordion/Accordion.tsx +88 -0
  10. package/src/accordion/accordion.css +78 -0
  11. package/src/accordion/index.ts +2 -0
  12. package/src/animatednumber/AnimatedNumber.tsx +71 -0
  13. package/src/animatednumber/index.ts +2 -0
  14. package/src/banner/Banner.tsx +84 -0
  15. package/src/banner/banner.css +56 -0
  16. package/src/banner/index.ts +2 -0
  17. package/src/breadcrumb/Breadcrumb.tsx +94 -0
  18. package/src/breadcrumb/breadcrumb.css +58 -0
  19. package/src/breadcrumb/index.ts +2 -0
  20. package/src/button/Button.tsx +98 -0
  21. package/src/button/button.css +190 -0
  22. package/src/button/index.ts +2 -0
  23. package/src/button.tsx +20 -0
  24. package/src/calendar/Calendar.tsx +190 -0
  25. package/src/calendar/calendar.css +142 -0
  26. package/src/calendar/index.ts +2 -0
  27. package/src/card/Card.tsx +88 -0
  28. package/src/card/card.css +115 -0
  29. package/src/card/index.ts +2 -0
  30. package/src/card.tsx +27 -0
  31. package/src/chart/Chart.tsx +186 -0
  32. package/src/chart/index.ts +2 -0
  33. package/src/checkbox/Checkbox.tsx +101 -0
  34. package/src/checkbox/checkbox.css +76 -0
  35. package/src/checkbox/index.ts +2 -0
  36. package/src/chips/Chips.tsx +146 -0
  37. package/src/chips/chips.css +90 -0
  38. package/src/chips/index.ts +2 -0
  39. package/src/code.tsx +11 -0
  40. package/src/datepicker/DatePicker.tsx +104 -0
  41. package/src/datepicker/datepicker.css +22 -0
  42. package/src/datepicker/index.ts +2 -0
  43. package/src/dragdrop/DragDrop.tsx +168 -0
  44. package/src/dragdrop/dragdrop.css +122 -0
  45. package/src/dragdrop/index.ts +2 -0
  46. package/src/dropdown/Dropdown.tsx +176 -0
  47. package/src/dropdown/dropdown.css +118 -0
  48. package/src/dropdown/index.ts +2 -0
  49. package/src/fileupload/FileUpload.tsx +144 -0
  50. package/src/fileupload/fileupload.css +118 -0
  51. package/src/fileupload/index.ts +2 -0
  52. package/src/index.ts +28 -0
  53. package/src/input/Input.tsx +121 -0
  54. package/src/input/index.ts +2 -0
  55. package/src/input/input.css +120 -0
  56. package/src/menu/Menu.tsx +118 -0
  57. package/src/menu/index.ts +2 -0
  58. package/src/menu/menu.css +117 -0
  59. package/src/modal/Modal.tsx +103 -0
  60. package/src/modal/index.ts +2 -0
  61. package/src/modal/modal.css +79 -0
  62. package/src/progressbar/ProgressBar.tsx +68 -0
  63. package/src/progressbar/index.ts +2 -0
  64. package/src/progressbar/progressbar.css +73 -0
  65. package/src/radiogroup/RadioGroup.tsx +73 -0
  66. package/src/radiogroup/index.ts +2 -0
  67. package/src/radiogroup/radiogroup.css +80 -0
  68. package/src/skeleton/Skeleton.tsx +61 -0
  69. package/src/skeleton/index.ts +2 -0
  70. package/src/skeleton/skeleton.css +46 -0
  71. package/src/spinner/Spinner.tsx +29 -0
  72. package/src/spinner/index.ts +2 -0
  73. package/src/spinner/spinner.css +39 -0
  74. package/src/stepper/Stepper.tsx +127 -0
  75. package/src/stepper/index.ts +2 -0
  76. package/src/stepper/stepper.css +141 -0
  77. package/src/tab/Tabs.tsx +71 -0
  78. package/src/tab/index.ts +2 -0
  79. package/src/tab/tab.css +92 -0
  80. package/src/table/Table.tsx +205 -0
  81. package/src/table/index.ts +2 -0
  82. package/src/table/table.css +119 -0
  83. package/src/textarea/Textarea.tsx +102 -0
  84. package/src/textarea/index.ts +2 -0
  85. package/src/textarea/textarea.css +85 -0
  86. package/src/toaster/Toaster.tsx +108 -0
  87. package/src/toaster/index.ts +2 -0
  88. package/src/toaster/toaster.css +113 -0
  89. package/src/toggle/Toggle.tsx +88 -0
  90. package/src/toggle/index.ts +2 -0
  91. package/src/toggle/toggle.css +63 -0
  92. package/src/typography/Typography.tsx +58 -0
  93. package/src/typography/index.ts +2 -0
  94. package/src/typography/typography.css +102 -0
  95. package/tsconfig.json +13 -0
@@ -0,0 +1,4 @@
1
+ import { config } from "@repo/eslint-config/react-internal";
2
+
3
+ /** @type {import("eslint").Linter.Config} */
4
+ export default config;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@galyan/ui",
3
+ "version": "0.0.1",
4
+ "description": "React UI Components with Galyan Design System",
5
+ "author": "Saksham Galyan",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Sakshamgalyan/Galyan-UI.git"
9
+ },
10
+ "license": "MIT",
11
+ "private": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./*": "./src/*/index.ts"
16
+ },
17
+ "scripts": {
18
+ "lint": "eslint . --max-warnings 0",
19
+ "build": "tsup src/index.ts --dts --format esm,cjs",
20
+ "check-types": "tsc --noEmit"
21
+ },
22
+ "devDependencies": {
23
+ "@repo/eslint-config": "workspace:*",
24
+ "@repo/typescript-config": "workspace:*",
25
+ "@types/node": "^22.15.3",
26
+ "@types/react": "19.2.2",
27
+ "@types/react-dom": "19.2.2",
28
+ "eslint": "^9.39.1",
29
+ "tsup": "^8.5.1",
30
+ "typescript": "5.9.2"
31
+ },
32
+ "dependencies": {
33
+ "@floating-ui/react": "^0.26.28",
34
+ "@galyan/theme": "workspace:*",
35
+ "react": "^19.2.0",
36
+ "react-dom": "^19.2.0",
37
+ "recharts": "^2.15.3"
38
+ },
39
+ "peerDependencies": {
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0"
42
+ }
43
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useState } from 'react';
4
+ import './accordion.css';
5
+
6
+ export interface AccordionItemDef {
7
+ id: string;
8
+ title: React.ReactNode;
9
+ content: React.ReactNode;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ export interface AccordionProps {
14
+ items: AccordionItemDef[];
15
+ /** 'single' = only one open at a time, 'multiple' = multiple can be open */
16
+ type?: 'single' | 'multiple';
17
+ defaultOpen?: string | string[];
18
+ flush?: boolean;
19
+ className?: string;
20
+ }
21
+
22
+ function AccordionPanel({ open, children }: { open: boolean; children: React.ReactNode }) {
23
+ const ref = useRef<HTMLDivElement>(null);
24
+ const height = open ? (ref.current?.scrollHeight ?? 0) : 0;
25
+
26
+ return (
27
+ <div
28
+ className="gy-accordion-panel"
29
+ style={{ height, transition: `height 250ms cubic-bezier(0.4, 0, 0.2, 1)`, overflow: 'hidden' }}
30
+ aria-hidden={!open}
31
+ >
32
+ <div ref={ref} className="gy-accordion-panel-inner">
33
+ {children}
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export function Accordion({ items, type = 'single', defaultOpen, flush = false, className = '' }: AccordionProps) {
40
+ const initOpen = defaultOpen
41
+ ? Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]
42
+ : [];
43
+
44
+ const [openIds, setOpenIds] = useState<Set<string>>(new Set(initOpen));
45
+
46
+ const toggle = (id: string) => {
47
+ setOpenIds((prev) => {
48
+ const next = new Set(prev);
49
+ if (next.has(id)) {
50
+ next.delete(id);
51
+ } else {
52
+ if (type === 'single') next.clear();
53
+ next.add(id);
54
+ }
55
+ return next;
56
+ });
57
+ };
58
+
59
+ return (
60
+ <div className={`gy-accordion ${className}`}>
61
+ {items.map((item) => {
62
+ const isOpen = openIds.has(item.id);
63
+ return (
64
+ <div
65
+ key={item.id}
66
+ className={`gy-accordion-item ${flush ? 'gy-accordion-item--flush' : ''}`}
67
+ >
68
+ <button
69
+ type="button"
70
+ className={`gy-accordion-trigger ${isOpen ? 'gy-accordion-trigger--open' : ''}`}
71
+ onClick={() => !item.disabled && toggle(item.id)}
72
+ aria-expanded={isOpen}
73
+ disabled={item.disabled}
74
+ >
75
+ <span className="gy-accordion-title">{item.title}</span>
76
+ <span className={`gy-accordion-icon ${isOpen ? 'gy-accordion-icon--open' : ''}`} aria-hidden="true">
77
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
78
+ <polyline points="4,6 8,10 12,6" />
79
+ </svg>
80
+ </span>
81
+ </button>
82
+ <AccordionPanel open={isOpen}>{item.content}</AccordionPanel>
83
+ </div>
84
+ );
85
+ })}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,78 @@
1
+ /* ── Accordion ───────────────────────────────────────────────────────────── */
2
+ .gy-accordion {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: var(--gy-spacing-1);
6
+ width: 100%;
7
+ }
8
+
9
+ .gy-accordion-item {
10
+ border: 1px solid var(--gy-border);
11
+ border-radius: var(--gy-radius-lg);
12
+ overflow: hidden;
13
+ }
14
+
15
+ .gy-accordion-item--flush {
16
+ border-radius: 0;
17
+ border-left: none;
18
+ border-right: none;
19
+ border-top: none;
20
+ }
21
+
22
+ .gy-accordion-trigger {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ width: 100%;
27
+ padding: var(--gy-spacing-4) var(--gy-spacing-4);
28
+ background: var(--gy-surface);
29
+ border: none;
30
+ cursor: pointer;
31
+ text-align: left;
32
+ gap: var(--gy-spacing-3);
33
+ transition: background-color var(--gy-duration-fast);
34
+ }
35
+
36
+ .gy-accordion-trigger:hover {
37
+ background: var(--gy-background-subtle);
38
+ }
39
+
40
+ .gy-accordion-trigger:focus-visible {
41
+ outline: 2px solid var(--gy-focus-outline);
42
+ outline-offset: -2px;
43
+ }
44
+
45
+ .gy-accordion-trigger--open {
46
+ background: var(--gy-background-subtle);
47
+ }
48
+
49
+ .gy-accordion-title {
50
+ font-size: var(--gy-font-size-sm);
51
+ font-weight: var(--gy-font-weight-semibold);
52
+ color: var(--gy-text);
53
+ flex: 1;
54
+ }
55
+
56
+ .gy-accordion-icon {
57
+ flex-shrink: 0;
58
+ color: var(--gy-text-subtle);
59
+ transition: transform var(--gy-duration-normal) var(--gy-ease-spring);
60
+ }
61
+
62
+ .gy-accordion-icon--open {
63
+ transform: rotate(180deg);
64
+ }
65
+
66
+ .gy-accordion-panel {
67
+ overflow: hidden;
68
+ background: var(--gy-surface);
69
+ }
70
+
71
+ .gy-accordion-panel-inner {
72
+ padding: 0 var(--gy-spacing-4) var(--gy-spacing-4);
73
+ font-size: var(--gy-font-size-sm);
74
+ color: var(--gy-text-muted);
75
+ line-height: var(--gy-line-height-relaxed);
76
+ border-top: 1px solid var(--gy-border);
77
+ padding-top: var(--gy-spacing-3);
78
+ }
@@ -0,0 +1,2 @@
1
+ export { Accordion } from './Accordion';
2
+ export type { AccordionProps, AccordionItemDef } from './Accordion';
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+
5
+ export interface AnimatedNumberProps {
6
+ value: number;
7
+ duration?: number;
8
+ format?: (n: number) => string;
9
+ className?: string;
10
+ prefix?: string;
11
+ suffix?: string;
12
+ decimals?: number;
13
+ easing?: 'linear' | 'easeOut' | 'easeInOut';
14
+ }
15
+
16
+ function easeOut(t: number) { return 1 - Math.pow(1 - t, 3); }
17
+ function easeInOut(t: number) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
18
+
19
+ export function AnimatedNumber({
20
+ value,
21
+ duration = 1000,
22
+ format,
23
+ className = '',
24
+ prefix = '',
25
+ suffix = '',
26
+ decimals = 0,
27
+ easing = 'easeOut',
28
+ }: AnimatedNumberProps) {
29
+ const [displayed, setDisplayed] = useState(0);
30
+ const startRef = useRef(0);
31
+ const startTimeRef = useRef<number | null>(null);
32
+ const rafRef = useRef<number>(0);
33
+ const prevValueRef = useRef(0);
34
+
35
+ useEffect(() => {
36
+ startRef.current = prevValueRef.current;
37
+ startTimeRef.current = null;
38
+
39
+ const easeFn = easing === 'linear' ? (t: number) => t : easing === 'easeOut' ? easeOut : easeInOut;
40
+
41
+ const animate = (time: number) => {
42
+ if (!startTimeRef.current) startTimeRef.current = time;
43
+ const elapsed = time - startTimeRef.current;
44
+ const t = Math.min(elapsed / duration, 1);
45
+ const eased = easeFn(t);
46
+ const current = startRef.current + (value - startRef.current) * eased;
47
+ setDisplayed(current);
48
+
49
+ if (t < 1) {
50
+ rafRef.current = requestAnimationFrame(animate);
51
+ } else {
52
+ prevValueRef.current = value;
53
+ }
54
+ };
55
+
56
+ cancelAnimationFrame(rafRef.current);
57
+ rafRef.current = requestAnimationFrame(animate);
58
+
59
+ return () => cancelAnimationFrame(rafRef.current);
60
+ }, [value, duration, easing]);
61
+
62
+ const formatted = format
63
+ ? format(displayed)
64
+ : displayed.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
65
+
66
+ return (
67
+ <span className={`gy-animated-number ${className}`} aria-live="polite" aria-atomic="true">
68
+ {prefix}{formatted}{suffix}
69
+ </span>
70
+ );
71
+ }
@@ -0,0 +1,2 @@
1
+ export { AnimatedNumber } from './AnimatedNumber';
2
+ export type { AnimatedNumberProps } from './AnimatedNumber';
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import './banner.css';
5
+
6
+ export type BannerVariant = 'info' | 'success' | 'warning' | 'danger' | 'neutral';
7
+ export type BannerLayout = 'inline' | 'full';
8
+
9
+ const icons: Record<BannerVariant, React.ReactNode> = {
10
+ info: (
11
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 3a1 1 0 110 2 1 1 0 010-2zm0 3a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 018 7z"/></svg>
12
+ ),
13
+ success: (
14
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm3.78 5.78a.75.75 0 00-1.06-1.06L7 9.44 5.28 7.72a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l4.25-4.25z"/></svg>
15
+ ),
16
+ warning: (
17
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5a.75.75 0 01.65.375l6 10.5A.75.75 0 0114 13.5H2a.75.75 0 01-.65-1.125l6-10.5A.75.75 0 018 1.5zm0 4a.75.75 0 01.75.75v3a.75.75 0 01-1.5 0v-3A.75.75 0 018 5.5zm0 6.5a1 1 0 110-2 1 1 0 010 2z"/></svg>
18
+ ),
19
+ danger: (
20
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 3a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 018 4zm0 7.5a1 1 0 110-2 1 1 0 010 2z"/></svg>
21
+ ),
22
+ neutral: (
23
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 3a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 018 4zm0 7.5a1 1 0 110-2 1 1 0 010 2z"/></svg>
24
+ ),
25
+ };
26
+
27
+ export interface BannerProps {
28
+ variant?: BannerVariant;
29
+ layout?: BannerLayout;
30
+ title?: string;
31
+ description?: React.ReactNode;
32
+ icon?: React.ReactNode;
33
+ dismissible?: boolean;
34
+ onDismiss?: () => void;
35
+ className?: string;
36
+ }
37
+
38
+ export function Banner({
39
+ variant = 'info',
40
+ layout = 'inline',
41
+ title,
42
+ description,
43
+ icon,
44
+ dismissible = false,
45
+ onDismiss,
46
+ className = '',
47
+ }: BannerProps) {
48
+ const [dismissed, setDismissed] = useState(false);
49
+
50
+ if (dismissed) return null;
51
+
52
+ const handleDismiss = () => {
53
+ setDismissed(true);
54
+ onDismiss?.();
55
+ };
56
+
57
+ return (
58
+ <div
59
+ className={[
60
+ 'gy-banner',
61
+ `gy-banner--${variant}`,
62
+ `gy-banner--${layout}`,
63
+ className,
64
+ ]
65
+ .filter(Boolean)
66
+ .join(' ')}
67
+ role="alert"
68
+ >
69
+ <span className="gy-banner__icon">{icon ?? icons[variant]}</span>
70
+ <div className="gy-banner__content">
71
+ {title && <div className="gy-banner__title">{title}</div>}
72
+ {description && <div className="gy-banner__description">{description}</div>}
73
+ </div>
74
+ {dismissible && (
75
+ <button className="gy-banner__close" onClick={handleDismiss} aria-label="Dismiss">
76
+ <svg width="12" height="12" viewBox="0 0 12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
77
+ <line x1="1" y1="1" x2="11" y2="11" />
78
+ <line x1="11" y1="1" x2="1" y2="11" />
79
+ </svg>
80
+ </button>
81
+ )}
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,56 @@
1
+ /* ── Banner ──────────────────────────────────────────────────────────────── */
2
+ .gy-banner {
3
+ display: flex;
4
+ align-items: flex-start;
5
+ gap: var(--gy-spacing-3);
6
+ padding: var(--gy-spacing-3) var(--gy-spacing-4);
7
+ border-radius: var(--gy-radius-lg);
8
+ border: 1px solid transparent;
9
+ position: relative;
10
+ }
11
+
12
+ .gy-banner--inline { border-radius: var(--gy-radius-lg); }
13
+ .gy-banner--full { border-radius: 0; }
14
+
15
+ /* Status variants */
16
+ .gy-banner--info { background: var(--gy-info-subtle); border-color: var(--gy-info-muted); color: var(--gy-info-emphasis); }
17
+ .gy-banner--success { background: var(--gy-success-subtle); border-color: var(--gy-success-muted); color: var(--gy-success-emphasis); }
18
+ .gy-banner--warning { background: var(--gy-warning-subtle); border-color: var(--gy-warning-muted); color: var(--gy-warning-emphasis); }
19
+ .gy-banner--danger { background: var(--gy-danger-subtle); border-color: var(--gy-danger-muted); color: var(--gy-danger-emphasis); }
20
+ .gy-banner--neutral { background: var(--gy-background-muted); border-color: var(--gy-border); color: var(--gy-text-muted); }
21
+
22
+ .gy-banner__icon {
23
+ flex-shrink: 0;
24
+ margin-top: 1px;
25
+ }
26
+
27
+ .gy-banner__content { flex: 1; }
28
+
29
+ .gy-banner__title {
30
+ font-size: var(--gy-font-size-sm);
31
+ font-weight: var(--gy-font-weight-semibold);
32
+ line-height: 1.4;
33
+ }
34
+
35
+ .gy-banner__description {
36
+ font-size: var(--gy-font-size-sm);
37
+ line-height: 1.5;
38
+ opacity: 0.9;
39
+ margin-top: 2px;
40
+ }
41
+
42
+ .gy-banner__close {
43
+ flex-shrink: 0;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ width: 1.5rem;
48
+ height: 1.5rem;
49
+ border-radius: var(--gy-radius-md);
50
+ cursor: pointer;
51
+ opacity: 0.7;
52
+ transition: opacity var(--gy-duration-fast), background var(--gy-duration-fast);
53
+ color: inherit;
54
+ }
55
+
56
+ .gy-banner__close:hover { opacity: 1; background: rgba(0,0,0,0.08); }
@@ -0,0 +1,2 @@
1
+ export { Banner } from './Banner';
2
+ export type { BannerProps, BannerVariant, BannerLayout } from './Banner';
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import './breadcrumb.css';
5
+
6
+ export interface BreadcrumbItem {
7
+ label: string;
8
+ href?: string;
9
+ onClick?: () => void;
10
+ }
11
+
12
+ export interface BreadcrumbProps {
13
+ items: BreadcrumbItem[];
14
+ separator?: React.ReactNode;
15
+ maxItems?: number;
16
+ className?: string;
17
+ }
18
+
19
+ const DefaultSeparator = () => (
20
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
21
+ <polyline points="4,2 8,6 4,10" />
22
+ </svg>
23
+ );
24
+
25
+ export function Breadcrumb({
26
+ items,
27
+ separator = <DefaultSeparator />,
28
+ maxItems,
29
+ className = '',
30
+ }: BreadcrumbProps) {
31
+ const [expanded, setExpanded] = useState(false);
32
+
33
+ const shouldCollapse = maxItems && items.length > maxItems + 1 && !expanded;
34
+ const displayed: React.ReactNode[] = [];
35
+
36
+ if (shouldCollapse) {
37
+ // Always show first and last
38
+ const first = items[0]!;
39
+ const last = items[items.length - 1]!;
40
+
41
+ displayed.push(renderItem(first, false, separator));
42
+ displayed.push(
43
+ <li key="ellipsis" className="gy-breadcrumb-item">
44
+ <span className="gy-breadcrumb-separator">{separator}</span>
45
+ <button
46
+ className="gy-breadcrumb-ellipsis"
47
+ onClick={() => setExpanded(true)}
48
+ aria-label="Show more breadcrumbs"
49
+ >
50
+ •••
51
+ </button>
52
+ </li>
53
+ );
54
+ displayed.push(renderItem(last, true, separator));
55
+ } else {
56
+ items.forEach((item, i) => {
57
+ displayed.push(renderItem(item, i === items.length - 1, separator, i));
58
+ });
59
+ }
60
+
61
+ return (
62
+ <nav aria-label="Breadcrumb">
63
+ <ol className={`gy-breadcrumb ${className}`}>
64
+ {displayed}
65
+ </ol>
66
+ </nav>
67
+ );
68
+ }
69
+
70
+ function renderItem(
71
+ item: BreadcrumbItem,
72
+ isCurrent: boolean,
73
+ separator: React.ReactNode,
74
+ key?: number
75
+ ) {
76
+ return (
77
+ <li key={key ?? item.label} className="gy-breadcrumb-item">
78
+ {key !== 0 && key !== undefined && (
79
+ <span className="gy-breadcrumb-separator" aria-hidden="true">{separator}</span>
80
+ )}
81
+ {isCurrent ? (
82
+ <span className="gy-breadcrumb-current" aria-current="page">{item.label}</span>
83
+ ) : (
84
+ <a
85
+ className="gy-breadcrumb-link"
86
+ href={item.href ?? '#'}
87
+ onClick={item.onClick ? (e) => { e.preventDefault(); item.onClick?.(); } : undefined}
88
+ >
89
+ {item.label}
90
+ </a>
91
+ )}
92
+ </li>
93
+ );
94
+ }
@@ -0,0 +1,58 @@
1
+ /* ── Breadcrumb ──────────────────────────────────────────────────────────── */
2
+ .gy-breadcrumb {
3
+ display: flex;
4
+ align-items: center;
5
+ flex-wrap: wrap;
6
+ gap: 0;
7
+ list-style: none;
8
+ padding: 0;
9
+ margin: 0;
10
+ font-size: var(--gy-font-size-sm);
11
+ }
12
+
13
+ .gy-breadcrumb-item {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: var(--gy-spacing-1-5);
17
+ }
18
+
19
+ .gy-breadcrumb-link {
20
+ color: var(--gy-text-muted);
21
+ text-decoration: none;
22
+ transition: color var(--gy-duration-fast);
23
+ border-radius: var(--gy-radius-sm);
24
+ padding: 1px 2px;
25
+ }
26
+
27
+ .gy-breadcrumb-link:hover { color: var(--gy-primary); }
28
+
29
+ .gy-breadcrumb-link:focus-visible {
30
+ outline: 2px solid var(--gy-focus-outline);
31
+ outline-offset: 2px;
32
+ }
33
+
34
+ .gy-breadcrumb-current {
35
+ color: var(--gy-text);
36
+ font-weight: var(--gy-font-weight-medium);
37
+ }
38
+
39
+ .gy-breadcrumb-separator {
40
+ color: var(--gy-text-subtle);
41
+ display: flex;
42
+ align-items: center;
43
+ user-select: none;
44
+ margin: 0 var(--gy-spacing-1);
45
+ }
46
+
47
+ .gy-breadcrumb-ellipsis {
48
+ color: var(--gy-text-subtle);
49
+ cursor: pointer;
50
+ padding: 1px var(--gy-spacing-1);
51
+ border-radius: var(--gy-radius-sm);
52
+ transition: color var(--gy-duration-fast), background var(--gy-duration-fast);
53
+ }
54
+
55
+ .gy-breadcrumb-ellipsis:hover {
56
+ color: var(--gy-text);
57
+ background: var(--gy-background-muted);
58
+ }
@@ -0,0 +1,2 @@
1
+ export { Breadcrumb } from './Breadcrumb';
2
+ export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb';
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import React, { forwardRef, useCallback, useRef } from 'react';
4
+ import './button.css';
5
+
6
+ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'soft' | 'link' | 'danger';
7
+ export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
8
+
9
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
10
+ variant?: ButtonVariant;
11
+ size?: ButtonSize;
12
+ loading?: boolean;
13
+ fullWidth?: boolean;
14
+ leftIcon?: React.ReactNode;
15
+ rightIcon?: React.ReactNode;
16
+ /** Render as a different element (e.g. 'a' for links) */
17
+ as?: React.ElementType;
18
+ href?: string;
19
+ }
20
+
21
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
22
+ {
23
+ variant = 'solid',
24
+ size = 'md',
25
+ loading = false,
26
+ fullWidth = false,
27
+ leftIcon,
28
+ rightIcon,
29
+ disabled,
30
+ children,
31
+ className = '',
32
+ onClick,
33
+ as: Component = 'button',
34
+ ...rest
35
+ },
36
+ ref
37
+ ) {
38
+ const rippleRef = useRef<HTMLSpanElement | null>(null);
39
+
40
+ const handleClick = useCallback(
41
+ (e: React.MouseEvent<HTMLButtonElement>) => {
42
+ // Ripple effect
43
+ const btn = e.currentTarget;
44
+ const rect = btn.getBoundingClientRect();
45
+ const size = Math.max(rect.width, rect.height);
46
+ const x = e.clientX - rect.left - size / 2;
47
+ const y = e.clientY - rect.top - size / 2;
48
+
49
+ const ripple = document.createElement('span');
50
+ ripple.className = 'gy-btn__ripple';
51
+ ripple.style.width = ripple.style.height = `${size}px`;
52
+ ripple.style.left = `${x}px`;
53
+ ripple.style.top = `${y}px`;
54
+ btn.appendChild(ripple);
55
+ setTimeout(() => ripple.remove(), 600);
56
+
57
+ onClick?.(e);
58
+ },
59
+ [onClick]
60
+ );
61
+
62
+ const isDisabled = disabled || loading;
63
+ const iconOnly = !children && (leftIcon || rightIcon);
64
+
65
+ const classes = [
66
+ 'gy-btn',
67
+ `gy-btn--${variant}`,
68
+ `gy-btn--${size}`,
69
+ fullWidth ? 'gy-btn--full' : '',
70
+ loading ? 'gy-btn--loading' : '',
71
+ iconOnly ? 'gy-btn--icon-only' : '',
72
+ isDisabled ? 'gy-btn--disabled' : '',
73
+ className,
74
+ ]
75
+ .filter(Boolean)
76
+ .join(' ');
77
+
78
+ return (
79
+ <Component
80
+ ref={ref}
81
+ className={classes}
82
+ disabled={isDisabled}
83
+ onClick={handleClick}
84
+ aria-busy={loading}
85
+ {...rest}
86
+ >
87
+ {loading ? (
88
+ <span className="gy-btn__spinner" aria-hidden="true" />
89
+ ) : (
90
+ leftIcon && <span className="gy-btn__icon gy-btn__icon--left" aria-hidden="true">{leftIcon}</span>
91
+ )}
92
+ {children && <span>{children}</span>}
93
+ {!loading && rightIcon && (
94
+ <span className="gy-btn__icon gy-btn__icon--right" aria-hidden="true">{rightIcon}</span>
95
+ )}
96
+ </Component>
97
+ );
98
+ });