aplance-ui 1.1.0 → 1.2.0
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.
- package/package.json +1 -1
- package/registry/components/primitives/infinite-scroll/infinite-scroll-context.ts +32 -0
- package/registry/components/primitives/infinite-scroll/infinite-scroll.module.css +118 -0
- package/registry/components/primitives/infinite-scroll/infinite-scroll.tsx +386 -0
- package/registry/components/primitives/popover/popover.module.css +16 -21
- package/registry/components/primitives/slider/slider-context.ts +50 -0
- package/registry/components/primitives/slider/slider.module.css +300 -0
- package/registry/components/primitives/slider/slider.tsx +1174 -0
- package/registry/components/primitives/slider/types.ts +140 -0
- package/registry/components/primitives/slider/utils.ts +88 -0
- package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +144 -0
- package/registry/components/ui/breadcrumbs/breadcrumbs.tsx +236 -0
- package/registry/components/ui/button/button.module.css +3 -9
- package/registry/components/ui/chart/chart.module.css +86 -70
- package/registry/components/ui/chart/chart.tsx +117 -31
- package/registry/components/ui/input-fields/common/input-field-common.module.css +39 -65
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +2 -6
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +47 -3
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +12 -12
- package/registry/components/ui/input-fields/media-input/media-input.module.css +12 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +10 -9
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +19 -5
- package/registry/components/ui/input-fields/media-input/utils.ts +13 -5
- package/registry/registry.json +64 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CSSProperties,
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
Ref,
|
|
6
|
+
} from "react";
|
|
7
|
+
import type { SliderApi } from "./slider-context";
|
|
8
|
+
|
|
9
|
+
export const sliderEffects = ["slide", "fade", "scale"] as const;
|
|
10
|
+
export type SliderEffect = (typeof sliderEffects)[number];
|
|
11
|
+
|
|
12
|
+
export const sliderAlignments = ["start", "center", "end"] as const;
|
|
13
|
+
export type SliderAlign = (typeof sliderAlignments)[number];
|
|
14
|
+
|
|
15
|
+
export const sliderPaginationTypes = ["dots", "fraction", "progress"] as const;
|
|
16
|
+
export type SliderPaginationType = (typeof sliderPaginationTypes)[number];
|
|
17
|
+
|
|
18
|
+
export type SliderSlidesPerView = number | "auto";
|
|
19
|
+
|
|
20
|
+
export type SliderResponsiveOptions = {
|
|
21
|
+
slidesPerView?: SliderSlidesPerView;
|
|
22
|
+
slidesPerGroup?: number;
|
|
23
|
+
spaceBetween?: number;
|
|
24
|
+
centered?: boolean;
|
|
25
|
+
align?: SliderAlign;
|
|
26
|
+
effect?: SliderEffect;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Responsive overrides keyed by minimum viewport width in px. */
|
|
30
|
+
export type SliderResponsive = Record<number, SliderResponsiveOptions>;
|
|
31
|
+
|
|
32
|
+
export type SliderAutoplayOptions = {
|
|
33
|
+
/** Milliseconds between auto-advances. */
|
|
34
|
+
delay?: number;
|
|
35
|
+
/** Pause autoplay while pointer hovers the slider. */
|
|
36
|
+
pauseOnHover?: boolean;
|
|
37
|
+
/** Pause autoplay while focus is inside the slider. */
|
|
38
|
+
pauseOnFocus?: boolean;
|
|
39
|
+
/** Permanently stop autoplay after the first user interaction. */
|
|
40
|
+
stopOnInteraction?: boolean;
|
|
41
|
+
/** Autoplay previous instead of next. */
|
|
42
|
+
reverseDirection?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SliderNavigationOptions = {
|
|
46
|
+
prevIcon?: ReactNode;
|
|
47
|
+
nextIcon?: ReactNode;
|
|
48
|
+
ariaPrev?: string;
|
|
49
|
+
ariaNext?: string;
|
|
50
|
+
className?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type SliderPaginationOptions = {
|
|
54
|
+
type?: SliderPaginationType;
|
|
55
|
+
/** When true, dots/fraction items are clickable. */
|
|
56
|
+
clickable?: boolean;
|
|
57
|
+
/** Paginate by snap pages or every real slide. */
|
|
58
|
+
items?: "snaps" | "slides";
|
|
59
|
+
className?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type SliderSlideState = {
|
|
63
|
+
index: number;
|
|
64
|
+
renderedIndex: number;
|
|
65
|
+
isActive: boolean;
|
|
66
|
+
isVisible: boolean;
|
|
67
|
+
isClone: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type SliderHandle = {
|
|
71
|
+
next: (steps?: number) => void;
|
|
72
|
+
prev: (steps?: number) => void;
|
|
73
|
+
goTo: (index: number) => void;
|
|
74
|
+
slideTo: (index: number) => void;
|
|
75
|
+
stopAutoplay: () => void;
|
|
76
|
+
index: number;
|
|
77
|
+
count: number;
|
|
78
|
+
snapCount: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type SliderProps = {
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
className?: string;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
ref?: Ref<SliderHandle>;
|
|
86
|
+
ariaLabel?: string;
|
|
87
|
+
|
|
88
|
+
/** Slide transition effect. `fade` and `scale` are single-view effects. */
|
|
89
|
+
effect?: SliderEffect;
|
|
90
|
+
/** Alignment of the active slide inside the viewport. */
|
|
91
|
+
align?: SliderAlign;
|
|
92
|
+
/** Center active slides. Alias for align="center". */
|
|
93
|
+
centered?: boolean;
|
|
94
|
+
|
|
95
|
+
/** Visible slides per viewport. Floats and "auto" are supported. */
|
|
96
|
+
slidesPerView?: SliderSlidesPerView;
|
|
97
|
+
/** Number of slides to move per next/previous action. */
|
|
98
|
+
slidesPerGroup?: number;
|
|
99
|
+
/** Pixel gap between slides. */
|
|
100
|
+
spaceBetween?: number;
|
|
101
|
+
/** Per-breakpoint overrides. */
|
|
102
|
+
responsive?: SliderResponsive;
|
|
103
|
+
|
|
104
|
+
/** Controlled current snap/slide index. */
|
|
105
|
+
index?: number;
|
|
106
|
+
defaultIndex?: number;
|
|
107
|
+
onIndexChange?: (index: number) => void;
|
|
108
|
+
onSlideChange?: (index: number) => void;
|
|
109
|
+
/** Wrap-around with cloned edges and an invisible snap-back. */
|
|
110
|
+
loop?: boolean;
|
|
111
|
+
/** When true, next on the last non-loop slide rewinds to the start. */
|
|
112
|
+
rewind?: boolean;
|
|
113
|
+
/** Transition duration in ms. */
|
|
114
|
+
speed?: number;
|
|
115
|
+
/** Easing for transform/opacity transitions. */
|
|
116
|
+
easing?: string;
|
|
117
|
+
/** Allow pointer drag / touch swipe. */
|
|
118
|
+
draggable?: boolean;
|
|
119
|
+
/** Minimum drag distance in px to commit a swipe. */
|
|
120
|
+
swipeThreshold?: number;
|
|
121
|
+
/** Selector for interactive elements that should not start a drag. */
|
|
122
|
+
dragIgnoreSelector?: string;
|
|
123
|
+
/** Enable left/right arrow keyboard navigation while focus is inside. */
|
|
124
|
+
keyboard?: boolean;
|
|
125
|
+
/** Enable trackpad/mouse-wheel navigation. */
|
|
126
|
+
mousewheel?: boolean;
|
|
127
|
+
/** Let the viewport height follow the active slide. */
|
|
128
|
+
autoHeight?: boolean;
|
|
129
|
+
|
|
130
|
+
autoplay?: boolean | SliderAutoplayOptions;
|
|
131
|
+
/** Pauses autoplay on hover when autoplay.pauseOnHover is enabled. */
|
|
132
|
+
pauseOnHover?: boolean;
|
|
133
|
+
navigation?: boolean | SliderNavigationOptions;
|
|
134
|
+
pagination?: boolean | SliderPaginationOptions;
|
|
135
|
+
|
|
136
|
+
/** Render arbitrary controls inside the context without making them slides. */
|
|
137
|
+
renderControls?: (api: SliderApi) => ReactNode;
|
|
138
|
+
/** Customize each slide wrapper's contents. */
|
|
139
|
+
renderSlide?: (child: ReactNode, state: SliderSlideState) => ReactNode;
|
|
140
|
+
} & Omit<ComponentPropsWithoutRef<"div">, "children" | "onChange">;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SliderResponsive,
|
|
3
|
+
SliderResponsiveOptions,
|
|
4
|
+
SliderSlidesPerView,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
export const clamp = (value: number, min: number, max: number) => {
|
|
8
|
+
const safeMin = Number.isFinite(min) ? min : 0;
|
|
9
|
+
const safeMax = Number.isFinite(max) ? max : safeMin;
|
|
10
|
+
const lower = Math.min(safeMin, safeMax);
|
|
11
|
+
const upper = Math.max(safeMin, safeMax);
|
|
12
|
+
const safeValue = Number.isFinite(value) ? value : lower;
|
|
13
|
+
|
|
14
|
+
return Math.min(Math.max(safeValue, lower), upper);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const wrap = (value: number, length: number) =>
|
|
18
|
+
!Number.isFinite(value) || !Number.isFinite(length) || length <= 0
|
|
19
|
+
? 0
|
|
20
|
+
: ((value % length) + length) % length;
|
|
21
|
+
|
|
22
|
+
export const getSnapMax = (
|
|
23
|
+
count: number,
|
|
24
|
+
slidesPerView: SliderSlidesPerView,
|
|
25
|
+
) => {
|
|
26
|
+
if (count <= 0) return 0;
|
|
27
|
+
if (slidesPerView === "auto") return count - 1;
|
|
28
|
+
return Math.max(0, count - Math.max(1, Math.floor(Math.max(1, slidesPerView))));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const normalizeIndex = (
|
|
32
|
+
index: number,
|
|
33
|
+
count: number,
|
|
34
|
+
maxIndex: number,
|
|
35
|
+
loop: boolean,
|
|
36
|
+
) => {
|
|
37
|
+
if (count <= 0) return 0;
|
|
38
|
+
const safeIndex = Number.isFinite(index) ? index : 0;
|
|
39
|
+
return loop ? wrap(safeIndex, count) : clamp(safeIndex, 0, maxIndex);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const resolveResponsive = (
|
|
43
|
+
width: number,
|
|
44
|
+
base: Required<SliderResponsiveOptions>,
|
|
45
|
+
responsive?: SliderResponsive,
|
|
46
|
+
): Required<SliderResponsiveOptions> => {
|
|
47
|
+
if (!responsive || width <= 0) return base;
|
|
48
|
+
|
|
49
|
+
const active = { ...base };
|
|
50
|
+
const breakpoints = Object.keys(responsive)
|
|
51
|
+
.map(Number)
|
|
52
|
+
.filter((value) => Number.isFinite(value))
|
|
53
|
+
.sort((a, b) => a - b);
|
|
54
|
+
|
|
55
|
+
for (const breakpoint of breakpoints) {
|
|
56
|
+
if (width < breakpoint) continue;
|
|
57
|
+
const options = responsive[breakpoint];
|
|
58
|
+
if (options.slidesPerView !== undefined) {
|
|
59
|
+
active.slidesPerView = options.slidesPerView;
|
|
60
|
+
}
|
|
61
|
+
if (options.slidesPerGroup !== undefined) {
|
|
62
|
+
active.slidesPerGroup = options.slidesPerGroup;
|
|
63
|
+
}
|
|
64
|
+
if (options.spaceBetween !== undefined) {
|
|
65
|
+
active.spaceBetween = options.spaceBetween;
|
|
66
|
+
}
|
|
67
|
+
if (options.centered !== undefined) {
|
|
68
|
+
active.centered = options.centered;
|
|
69
|
+
}
|
|
70
|
+
if (options.align !== undefined) {
|
|
71
|
+
active.align = options.align;
|
|
72
|
+
}
|
|
73
|
+
if (options.effect !== undefined) {
|
|
74
|
+
active.effect = options.effect;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return active;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const shouldIgnoreDrag = (target: EventTarget | null, selector: string) => {
|
|
82
|
+
if (!(target instanceof Element)) return false;
|
|
83
|
+
try {
|
|
84
|
+
return Boolean(target.closest(selector));
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* ─── Container ──────────────────────────────────────────────────────────────
|
|
2
|
+
`nav` wrapper. Tokens declared on the root let consumers theme individual
|
|
3
|
+
parts via CSS variables without overrides.
|
|
4
|
+
─────────────────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
.container {
|
|
7
|
+
--breadcrumbs-color: var(--color-muted-foreground);
|
|
8
|
+
--breadcrumbs-color-hover: var(--color-foreground);
|
|
9
|
+
--breadcrumbs-color-current: var(--color-foreground);
|
|
10
|
+
--breadcrumbs-color-separator: var(--color-muted-foreground);
|
|
11
|
+
--breadcrumbs-font-size: var(--font-size-sm);
|
|
12
|
+
--breadcrumbs-gap: var(--spacing-3);
|
|
13
|
+
--breadcrumbs-padding: var(--spacing-2) var(--spacing-3);
|
|
14
|
+
|
|
15
|
+
display: block;
|
|
16
|
+
width: 100%;
|
|
17
|
+
min-width: 0;
|
|
18
|
+
color: var(--breadcrumbs-color);
|
|
19
|
+
font-size: var(--breadcrumbs-font-size);
|
|
20
|
+
line-height: var(--line-height-md);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.size_sm {
|
|
24
|
+
--breadcrumbs-font-size: var(--font-size-xs);
|
|
25
|
+
--breadcrumbs-gap: var(--spacing-2);
|
|
26
|
+
--breadcrumbs-padding: var(--spacing-1) var(--spacing-2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ─── List ───────────────────────────────────────────────────────────────── */
|
|
30
|
+
|
|
31
|
+
.list {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: var(--breadcrumbs-gap);
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
list-style: none;
|
|
39
|
+
min-width: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.item {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
min-width: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ─── Crumb (link / button / span) ───────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
.crumb {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: var(--spacing-2);
|
|
54
|
+
min-width: 0;
|
|
55
|
+
padding: var(--breadcrumbs-padding);
|
|
56
|
+
border-radius: var(--radius-md);
|
|
57
|
+
color: inherit;
|
|
58
|
+
background-color: transparent;
|
|
59
|
+
font: inherit;
|
|
60
|
+
border: 0;
|
|
61
|
+
text-decoration: none;
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
cursor: default;
|
|
64
|
+
transition: var(--transition-fast);
|
|
65
|
+
transition-property: background-color, color;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.crumb_link {
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
color: var(--breadcrumbs-color);
|
|
71
|
+
|
|
72
|
+
&:hover {
|
|
73
|
+
color: var(--breadcrumbs-color-hover);
|
|
74
|
+
background-color: var(--color-muted);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
&:focus-visible {
|
|
78
|
+
outline: var(--outline-2) solid var(--color-ring);
|
|
79
|
+
outline-offset: 2px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&[aria-disabled="true"],
|
|
83
|
+
&:disabled {
|
|
84
|
+
cursor: not-allowed;
|
|
85
|
+
opacity: 0.55;
|
|
86
|
+
pointer-events: none;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.crumb_current {
|
|
91
|
+
color: var(--breadcrumbs-color-current);
|
|
92
|
+
font-weight: var(--font-weight-medium);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.crumb_disabled {
|
|
96
|
+
opacity: 0.55;
|
|
97
|
+
cursor: not-allowed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ─── Icon + label ───────────────────────────────────────────────────────── */
|
|
101
|
+
|
|
102
|
+
.icon {
|
|
103
|
+
display: inline-flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
line-height: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.icon > svg {
|
|
110
|
+
width: 1em;
|
|
111
|
+
height: 1em;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.label {
|
|
115
|
+
min-width: 0;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
text-overflow: ellipsis;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ─── Separator + ellipsis ───────────────────────────────────────────────── */
|
|
121
|
+
|
|
122
|
+
.separator {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
color: var(--breadcrumbs-color-separator);
|
|
126
|
+
flex-shrink: 0;
|
|
127
|
+
user-select: none;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.separator_icon {
|
|
131
|
+
width: 0.875em;
|
|
132
|
+
height: 0.875em;
|
|
133
|
+
color: inherit;
|
|
134
|
+
flex-shrink: 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.ellipsis {
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
padding: 0 var(--spacing-1);
|
|
141
|
+
color: var(--breadcrumbs-color-separator);
|
|
142
|
+
letter-spacing: 0.05em;
|
|
143
|
+
user-select: none;
|
|
144
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Fragment,
|
|
3
|
+
type ComponentProps,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ElementType,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import clsx from "clsx";
|
|
10
|
+
import { IconChevronRight } from "@tabler/icons-react";
|
|
11
|
+
import s from "./breadcrumbs.module.css";
|
|
12
|
+
|
|
13
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const breadcrumbsSizes = ["sm", "md"] as const;
|
|
16
|
+
export type BreadcrumbsSize = (typeof breadcrumbsSizes)[number];
|
|
17
|
+
|
|
18
|
+
type BreadcrumbClickHandler = (
|
|
19
|
+
event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
|
|
20
|
+
) => void;
|
|
21
|
+
|
|
22
|
+
export type BreadcrumbItem = {
|
|
23
|
+
/** Stable id when labels can repeat (defaults to the array index). */
|
|
24
|
+
id?: string;
|
|
25
|
+
label: ReactNode;
|
|
26
|
+
/** Renders the crumb as a link when set. */
|
|
27
|
+
href?: string;
|
|
28
|
+
/** Optional leading icon (use an `@tabler/icons-react` icon). */
|
|
29
|
+
icon?: ReactNode;
|
|
30
|
+
onClick?: BreadcrumbClickHandler;
|
|
31
|
+
/** Marks the current page. Defaults to the last item when unset. */
|
|
32
|
+
isCurrent?: boolean;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Per-item link component override (e.g. Next.js Link). */
|
|
35
|
+
linkComponent?: ElementType;
|
|
36
|
+
/** Extra props forwarded to the link element. */
|
|
37
|
+
linkProps?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BreadcrumbsProps = {
|
|
41
|
+
items: BreadcrumbItem[];
|
|
42
|
+
/** Custom separator node. Defaults to a chevron icon. */
|
|
43
|
+
separator?: ReactNode;
|
|
44
|
+
size?: BreadcrumbsSize;
|
|
45
|
+
/**
|
|
46
|
+
* Collapse middle items into `...` when items exceed this count.
|
|
47
|
+
* `0` (default) disables truncation.
|
|
48
|
+
*/
|
|
49
|
+
maxItems?: number;
|
|
50
|
+
/** Items kept at the start when collapsing. Default 1. */
|
|
51
|
+
itemsBeforeCollapse?: number;
|
|
52
|
+
/** Items kept at the end when collapsing. Default 2. */
|
|
53
|
+
itemsAfterCollapse?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Router-agnostic link component used for all linked items unless an item
|
|
56
|
+
* provides its own `linkComponent`.
|
|
57
|
+
*/
|
|
58
|
+
linkComponent?: ElementType;
|
|
59
|
+
ariaLabel?: string;
|
|
60
|
+
className?: string;
|
|
61
|
+
style?: CSSProperties;
|
|
62
|
+
} & Omit<ComponentProps<"nav">, "children" | "aria-label">;
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const ELLIPSIS_KEY = "__breadcrumbs-ellipsis__";
|
|
67
|
+
|
|
68
|
+
type ResolvedItem = BreadcrumbItem & { isCurrent: boolean; key: string };
|
|
69
|
+
type ListEntry = ResolvedItem | typeof ELLIPSIS_KEY;
|
|
70
|
+
|
|
71
|
+
const resolveItems = (items: BreadcrumbItem[]): ResolvedItem[] =>
|
|
72
|
+
items.map((item, index) => ({
|
|
73
|
+
...item,
|
|
74
|
+
isCurrent: item.isCurrent ?? index === items.length - 1,
|
|
75
|
+
key: item.id ?? String(index),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Inserts a sentinel where the middle items would have been so the renderer
|
|
79
|
+
// can drop in a single ellipsis separator without re-walking the array.
|
|
80
|
+
const collapseItems = (
|
|
81
|
+
items: ResolvedItem[],
|
|
82
|
+
maxItems: number,
|
|
83
|
+
before: number,
|
|
84
|
+
after: number,
|
|
85
|
+
): ListEntry[] => {
|
|
86
|
+
if (!maxItems || items.length <= maxItems) return items;
|
|
87
|
+
const start = Math.max(0, before);
|
|
88
|
+
const end = Math.max(0, after);
|
|
89
|
+
if (start + end >= items.length) return items;
|
|
90
|
+
return [
|
|
91
|
+
...items.slice(0, start),
|
|
92
|
+
ELLIPSIS_KEY,
|
|
93
|
+
...items.slice(items.length - end),
|
|
94
|
+
];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ─── Breadcrumbs ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export const Breadcrumbs = ({
|
|
100
|
+
items,
|
|
101
|
+
separator,
|
|
102
|
+
size = "md",
|
|
103
|
+
maxItems = 0,
|
|
104
|
+
itemsBeforeCollapse = 1,
|
|
105
|
+
itemsAfterCollapse = 2,
|
|
106
|
+
linkComponent,
|
|
107
|
+
ariaLabel = "Breadcrumb",
|
|
108
|
+
className,
|
|
109
|
+
style,
|
|
110
|
+
...rest
|
|
111
|
+
}: BreadcrumbsProps) => {
|
|
112
|
+
if (items.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
const resolved = resolveItems(items);
|
|
115
|
+
const entries = collapseItems(
|
|
116
|
+
resolved,
|
|
117
|
+
maxItems,
|
|
118
|
+
itemsBeforeCollapse,
|
|
119
|
+
itemsAfterCollapse,
|
|
120
|
+
);
|
|
121
|
+
// Render the chevron directly (no <Icon> wrapper) so the em-based sizing in
|
|
122
|
+
// `.separator_icon` actually applies and the chevron scales with the
|
|
123
|
+
// breadcrumb's font-size across size variants.
|
|
124
|
+
const separatorNode = separator ?? (
|
|
125
|
+
<IconChevronRight className={s.separator_icon} />
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<nav
|
|
130
|
+
className={clsx(s.container, s[`size_${size}`], className)}
|
|
131
|
+
style={style}
|
|
132
|
+
aria-label={ariaLabel}
|
|
133
|
+
{...rest}
|
|
134
|
+
>
|
|
135
|
+
<ol className={s.list}>
|
|
136
|
+
{entries.map((entry, index) => {
|
|
137
|
+
const isLast = index === entries.length - 1;
|
|
138
|
+
const separatorEl = !isLast ? (
|
|
139
|
+
<li aria-hidden="true" className={s.separator}>
|
|
140
|
+
{separatorNode}
|
|
141
|
+
</li>
|
|
142
|
+
) : null;
|
|
143
|
+
|
|
144
|
+
if (entry === ELLIPSIS_KEY) {
|
|
145
|
+
return (
|
|
146
|
+
<Fragment key={`ellipsis-${index}`}>
|
|
147
|
+
<li className={s.ellipsis} aria-hidden="true">
|
|
148
|
+
…
|
|
149
|
+
</li>
|
|
150
|
+
{separatorEl}
|
|
151
|
+
</Fragment>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Fragment key={entry.key}>
|
|
157
|
+
<li className={s.item}>
|
|
158
|
+
<BreadcrumbCrumb item={entry} linkComponent={linkComponent} />
|
|
159
|
+
</li>
|
|
160
|
+
{separatorEl}
|
|
161
|
+
</Fragment>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</ol>
|
|
165
|
+
</nav>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// ─── Crumb (single item) ──────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
type BreadcrumbCrumbProps = {
|
|
172
|
+
item: ResolvedItem;
|
|
173
|
+
linkComponent?: ElementType;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const BreadcrumbCrumb = ({ item, linkComponent }: BreadcrumbCrumbProps) => {
|
|
177
|
+
const { label, icon, href, onClick, isCurrent, disabled } = item;
|
|
178
|
+
const content = (
|
|
179
|
+
<>
|
|
180
|
+
{icon ? <span className={s.icon}>{icon}</span> : null}
|
|
181
|
+
<span className={s.label}>{label}</span>
|
|
182
|
+
</>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Current page: never a link. aria-current="page" lets assistive tech mark
|
|
186
|
+
// the destination so users don't navigate to where they already are.
|
|
187
|
+
if (isCurrent) {
|
|
188
|
+
return (
|
|
189
|
+
<span
|
|
190
|
+
className={clsx(s.crumb, s.crumb_current)}
|
|
191
|
+
aria-current="page"
|
|
192
|
+
aria-disabled={disabled || undefined}
|
|
193
|
+
>
|
|
194
|
+
{content}
|
|
195
|
+
</span>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const isLink = Boolean(href) && !disabled;
|
|
200
|
+
if (isLink) {
|
|
201
|
+
const LinkElement = item.linkComponent ?? linkComponent ?? "a";
|
|
202
|
+
return (
|
|
203
|
+
<LinkElement
|
|
204
|
+
className={clsx(s.crumb, s.crumb_link)}
|
|
205
|
+
href={href}
|
|
206
|
+
onClick={onClick}
|
|
207
|
+
aria-disabled={disabled || undefined}
|
|
208
|
+
{...(item.linkProps ?? {})}
|
|
209
|
+
>
|
|
210
|
+
{content}
|
|
211
|
+
</LinkElement>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (onClick && !disabled) {
|
|
216
|
+
return (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className={clsx(s.crumb, s.crumb_link)}
|
|
220
|
+
onClick={onClick}
|
|
221
|
+
disabled={disabled}
|
|
222
|
+
>
|
|
223
|
+
{content}
|
|
224
|
+
</button>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<span
|
|
230
|
+
className={clsx(s.crumb, disabled && s.crumb_disabled)}
|
|
231
|
+
aria-disabled={disabled || undefined}
|
|
232
|
+
>
|
|
233
|
+
{content}
|
|
234
|
+
</span>
|
|
235
|
+
);
|
|
236
|
+
};
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
with selectors for different variants and sizes and states
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
.base {
|
|
1
|
+
.base {
|
|
6
2
|
--button-fg: var(--color-foreground);
|
|
7
3
|
--button-placeholder: var(--button-fg);
|
|
8
4
|
--button-icon: var(--button-fg);
|
|
@@ -28,8 +24,7 @@ with selectors for different variants and sizes and states
|
|
|
28
24
|
outline: none;
|
|
29
25
|
border: none;
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
line-height: 1.125;
|
|
27
|
+
line-height: 1.125;
|
|
33
28
|
|
|
34
29
|
svg {
|
|
35
30
|
cursor: pointer;
|
|
@@ -41,8 +36,7 @@ with selectors for different variants and sizes and states
|
|
|
41
36
|
font-size: var(--font-size-xs);
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
&.box {
|
|
39
|
+
&.box {
|
|
46
40
|
width: var(--spacing-11);
|
|
47
41
|
min-height: unset;
|
|
48
42
|
justify-content: center;
|