@glyphui/ui 0.2.0 → 0.3.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/dist/chart.d.ts +202 -0
- package/dist/chart.js +457 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-CCecIzdq.js → field-CWZ_FdkD.js} +2 -1
- package/dist/{field-context-B6WOcK5m.js → field-context-anGPYXbW.js} +2 -56
- package/dist/form.js +3 -2
- package/dist/index.d.ts +2285 -241
- package/dist/index.js +2819 -266
- package/dist/{states-Rp8wZ_jH.js → states-DR-OoDT-.js} +2 -1
- package/dist/tv-DBAGlgXJ.js +58 -0
- package/package.json +17 -2
- package/src/components/accordion/accordion.styles.ts +4 -4
- package/src/components/calendar/calendar.styles.ts +50 -0
- package/src/components/calendar/calendar.tsx +84 -0
- package/src/components/calendar/index.ts +3 -0
- package/src/components/carousel/carousel.styles.ts +43 -0
- package/src/components/carousel/carousel.tsx +278 -0
- package/src/components/carousel/index.ts +13 -0
- package/src/components/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -0
- package/src/components/context-menu/context-menu.styles.ts +19 -0
- package/src/components/context-menu/context-menu.tsx +226 -0
- package/src/components/context-menu/index.ts +19 -0
- package/src/components/copy-button/copy-button.tsx +117 -0
- package/src/components/copy-button/index.ts +1 -0
- package/src/components/date-picker/date-picker.styles.ts +42 -0
- package/src/components/date-picker/date-picker.tsx +322 -0
- package/src/components/date-picker/index.ts +8 -0
- package/src/components/file-upload/file-upload.styles.ts +43 -0
- package/src/components/file-upload/file-upload.tsx +343 -0
- package/src/components/file-upload/index.ts +10 -0
- package/src/components/hover-card/hover-card.tsx +41 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/input-otp/index.ts +10 -0
- package/src/components/input-otp/input-otp.styles.ts +38 -0
- package/src/components/input-otp/input-otp.tsx +116 -0
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +18 -0
- package/src/components/modal/modal.tsx +86 -0
- package/src/components/navigation-menu/index.ts +14 -0
- package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
- package/src/components/navigation-menu/navigation-menu.tsx +198 -0
- package/src/components/number-input/index.ts +2 -0
- package/src/components/number-input/number-input.styles.ts +41 -0
- package/src/components/number-input/number-input.tsx +211 -0
- package/src/components/password-input/index.ts +7 -0
- package/src/components/password-input/password-input.styles.ts +30 -0
- package/src/components/password-input/password-input.tsx +127 -0
- package/src/components/resizable/index.ts +9 -0
- package/src/components/resizable/resizable.styles.ts +28 -0
- package/src/components/resizable/resizable.tsx +69 -0
- package/src/components/sheet/sheet.styles.ts +7 -7
- package/src/components/stepper/index.ts +2 -0
- package/src/components/stepper/stepper.styles.ts +74 -0
- package/src/components/stepper/stepper.tsx +138 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.styles.ts +39 -0
- package/src/components/tag-input/tag-input.tsx +198 -0
- package/src/components/timeline/index.ts +8 -0
- package/src/components/timeline/timeline.styles.ts +56 -0
- package/src/components/timeline/timeline.tsx +91 -0
- package/src/components/toggle/index.ts +9 -0
- package/src/components/toggle/toggle.styles.ts +39 -0
- package/src/components/toggle/toggle.tsx +61 -0
- package/src/components/tree-view/index.ts +2 -0
- package/src/components/tree-view/tree-view.styles.ts +42 -0
- package/src/components/tree-view/tree-view.tsx +274 -0
- package/src/components/visually-hidden/index.ts +1 -0
- package/src/components/visually-hidden/visually-hidden.tsx +10 -0
- package/src/index.ts +20 -0
- package/src/patterns/chart/chart-parts.tsx +171 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +369 -0
- package/src/patterns/chart/index.ts +13 -0
- package/styles.css +16 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { a as themedBorder, o as transition, r as focusRing, s as useComponentConfig, t as tv } from "./tv-DBAGlgXJ.js";
|
|
3
|
+
import { c as AlertIcon, d as CheckIcon, g as InboxIcon, n as useFieldControl, o as Spinner, r as Button, v as MinusIcon } from "./field-context-anGPYXbW.js";
|
|
3
4
|
import { Checkbox } from "radix-ui";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
//#region src/components/checkbox/checkbox.tsx
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { textTokens } from "@glyphui/tokens";
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
import { createTV } from "tailwind-variants";
|
|
5
|
+
//#region src/provider/glyph-context.ts
|
|
6
|
+
const GlyphContext = createContext(null);
|
|
7
|
+
/** The active theme and app-level settings. Must be used under `GlyphProvider`. */
|
|
8
|
+
function useGlyph() {
|
|
9
|
+
const value = useContext(GlyphContext);
|
|
10
|
+
if (!value) throw new Error("useGlyph must be used inside <GlyphProvider>.");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** The router-aware link component configured on `GlyphProvider`. */
|
|
14
|
+
function useLinkComponent() {
|
|
15
|
+
return useContext(GlyphContext)?.linkComponent ?? "a";
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/hooks/use-component-config.ts
|
|
19
|
+
/**
|
|
20
|
+
* Applies the theme's per-component settings (`theme.components[name]`):
|
|
21
|
+
* fills unset variant props from `defaultVariants` and returns the extra slot classes.
|
|
22
|
+
* Every styled component calls this first.
|
|
23
|
+
*/
|
|
24
|
+
function useComponentConfig(name, props) {
|
|
25
|
+
const override = (useContext(GlyphContext)?.theme)?.components[name];
|
|
26
|
+
if (!override) return [props, {}];
|
|
27
|
+
let merged = props;
|
|
28
|
+
const defaults = override.defaultVariants;
|
|
29
|
+
if (defaults) {
|
|
30
|
+
merged = { ...props };
|
|
31
|
+
for (const [key, value] of Object.entries(defaults)) if (merged[key] === void 0) merged[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return [merged, override.className ?? {}];
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/utils/styles.ts
|
|
37
|
+
/** Shared class fragments. Kept as literals so Tailwind's scanner picks them up. */
|
|
38
|
+
const focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
|
|
39
|
+
const themedBorder = "border-(length:--glyph-border-width)";
|
|
40
|
+
const transition = "transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-(--glyph-duration-base) ease-standard";
|
|
41
|
+
/** Popover surfaces: Select, Tooltip-like menus. */
|
|
42
|
+
const popover = "z-50 overflow-hidden rounded-[min(var(--radius-md),var(--radius-lg))] border-(length:--glyph-border-width) border-border bg-raised text-foreground shadow-lg animate-pop-in";
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/utils/tw-merge-config.ts
|
|
45
|
+
/**
|
|
46
|
+
* Teaches tailwind-merge about Glyph's custom utilities so `cn('text-h1', 'text-small')`
|
|
47
|
+
* keeps only the last size instead of treating `text-h1` as a color.
|
|
48
|
+
*/
|
|
49
|
+
const twMergeConfig = { extend: { theme: {
|
|
50
|
+
text: [...textTokens],
|
|
51
|
+
radius: ["pill"]
|
|
52
|
+
} } };
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/utils/tv.ts
|
|
55
|
+
/** `tailwind-variants` preconfigured with Glyph's merge rules. All component styles use this. */
|
|
56
|
+
const tv = createTV({ twMergeConfig });
|
|
57
|
+
//#endregion
|
|
58
|
+
export { themedBorder as a, GlyphContext as c, popover as i, useGlyph as l, twMergeConfig as n, transition as o, focusRing as r, useComponentConfig as s, tv as t, useLinkComponent as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glyphui/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React components on Tailwind CSS v4 that take their entire look from one theme file. Forms, data tables, app layouts and async states included.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mohak Gupta (https://github.com/khalmohak)",
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"types": "./dist/data-table.d.ts",
|
|
47
47
|
"default": "./dist/data-table.js"
|
|
48
48
|
},
|
|
49
|
+
"./chart": {
|
|
50
|
+
"@glyphui/source": "./src/patterns/chart/index.ts",
|
|
51
|
+
"types": "./dist/chart.d.ts",
|
|
52
|
+
"default": "./dist/chart.js"
|
|
53
|
+
},
|
|
49
54
|
"./styles.css": "./styles.css",
|
|
50
55
|
"./package.json": "./package.json"
|
|
51
56
|
},
|
|
@@ -58,11 +63,16 @@
|
|
|
58
63
|
"!src/test"
|
|
59
64
|
],
|
|
60
65
|
"dependencies": {
|
|
61
|
-
"@glyphui/tokens": "^0.
|
|
66
|
+
"@glyphui/tokens": "^0.3.0",
|
|
62
67
|
"@standard-schema/spec": "^1.1.0",
|
|
63
68
|
"clsx": "^2.1.1",
|
|
64
69
|
"cmdk": "^1.1.1",
|
|
70
|
+
"date-fns": "^4.4.0",
|
|
71
|
+
"embla-carousel-react": "^8.6.0",
|
|
72
|
+
"input-otp": "^1.5.0",
|
|
65
73
|
"radix-ui": "^1.6.7",
|
|
74
|
+
"react-day-picker": "^9.14.0",
|
|
75
|
+
"react-resizable-panels": "^3.0.6",
|
|
66
76
|
"sonner": "^2.0.8",
|
|
67
77
|
"tailwind-merge": "^3.7.0",
|
|
68
78
|
"tailwind-variants": "^3.3.1"
|
|
@@ -73,6 +83,7 @@
|
|
|
73
83
|
"react": ">=19",
|
|
74
84
|
"react-dom": ">=19",
|
|
75
85
|
"react-hook-form": "^7.55.0",
|
|
86
|
+
"recharts": "^3.0.0",
|
|
76
87
|
"tailwindcss": ">=4.1"
|
|
77
88
|
},
|
|
78
89
|
"peerDependenciesMeta": {
|
|
@@ -84,6 +95,9 @@
|
|
|
84
95
|
},
|
|
85
96
|
"react-hook-form": {
|
|
86
97
|
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"recharts": {
|
|
100
|
+
"optional": true
|
|
87
101
|
}
|
|
88
102
|
},
|
|
89
103
|
"devDependencies": {
|
|
@@ -100,6 +114,7 @@
|
|
|
100
114
|
"react": "^19.2.8",
|
|
101
115
|
"react-dom": "^19.2.8",
|
|
102
116
|
"react-hook-form": "^7.89.0",
|
|
117
|
+
"recharts": "^3.10.1",
|
|
103
118
|
"tsdown": "^0.23.0",
|
|
104
119
|
"typescript": "~5.9.3",
|
|
105
120
|
"vitest": "^3.2.7",
|
|
@@ -15,10 +15,10 @@ export const accordionStyles = tv({
|
|
|
15
15
|
],
|
|
16
16
|
chevron:
|
|
17
17
|
'size-4 shrink-0 text-faint-foreground transition-transform duration-(--glyph-duration-base) ease-standard group-data-[state=open]:rotate-180',
|
|
18
|
-
content:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
content:
|
|
19
|
+
'overflow-hidden text-small text-muted-foreground data-[state=open]:animate-collapse-down data-[state=closed]:animate-collapse-up',
|
|
20
|
+
// Height animates open and closed using Radix's measured content height.
|
|
21
|
+
contentInner: 'pb-4',
|
|
22
22
|
},
|
|
23
23
|
variants: {
|
|
24
24
|
variant: {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { focusRing, transition } from '../../utils/styles'
|
|
3
|
+
import { tv } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const calendarStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'relative w-fit p-3 text-small text-foreground',
|
|
8
|
+
months: 'relative flex flex-col gap-6 sm:flex-row',
|
|
9
|
+
month: 'grid content-start gap-3',
|
|
10
|
+
monthCaption: 'flex h-8 items-center justify-center px-9',
|
|
11
|
+
captionLabel: 'font-medium text-foreground',
|
|
12
|
+
nav: 'pointer-events-none absolute inset-x-3 top-3 z-10 flex items-center justify-between',
|
|
13
|
+
navButton: [
|
|
14
|
+
'pointer-events-auto inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground',
|
|
15
|
+
'hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40',
|
|
16
|
+
transition,
|
|
17
|
+
focusRing,
|
|
18
|
+
],
|
|
19
|
+
chevron: 'size-4',
|
|
20
|
+
monthGrid: 'border-collapse',
|
|
21
|
+
weekdays: '',
|
|
22
|
+
weekday: 'size-9 p-0 text-caption font-normal text-faint-foreground',
|
|
23
|
+
weeks: '',
|
|
24
|
+
week: '',
|
|
25
|
+
day: 'size-9 p-0 text-center align-middle',
|
|
26
|
+
dayButton: [
|
|
27
|
+
'inline-flex size-9 cursor-pointer items-center justify-center rounded-md text-small tabular-nums text-foreground',
|
|
28
|
+
'hover:bg-muted',
|
|
29
|
+
transition,
|
|
30
|
+
focusRing,
|
|
31
|
+
],
|
|
32
|
+
today: '[&>button]:font-semibold [&>button]:text-primary',
|
|
33
|
+
selected:
|
|
34
|
+
'[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary/90',
|
|
35
|
+
outside: '[&>button]:text-faint-foreground [&>button]:opacity-60',
|
|
36
|
+
disabled: '[&>button]:pointer-events-none [&>button]:opacity-35 [&>button]:line-through',
|
|
37
|
+
rangeStart: 'rounded-l-md bg-primary/14',
|
|
38
|
+
rangeMiddle:
|
|
39
|
+
'bg-primary/14 [&>button]:rounded-none [&>button]:bg-transparent! [&>button]:text-foreground! [&>button]:hover:bg-primary/10!',
|
|
40
|
+
rangeEnd: 'rounded-r-md bg-primary/14',
|
|
41
|
+
hidden: 'invisible',
|
|
42
|
+
footer: 'pt-2 text-small text-muted-foreground',
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
declare module '@glyphui/tokens' {
|
|
47
|
+
interface GlyphComponentRegistry {
|
|
48
|
+
Calendar: StylesEntry<typeof calendarStyles>
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DayPicker, type DayPickerProps } from 'react-day-picker'
|
|
2
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
3
|
+
import { calendarStyles } from './calendar.styles'
|
|
4
|
+
|
|
5
|
+
export type CalendarProps = DayPickerProps
|
|
6
|
+
|
|
7
|
+
function ChevronIcon({
|
|
8
|
+
className,
|
|
9
|
+
orientation = 'left',
|
|
10
|
+
}: {
|
|
11
|
+
className?: string
|
|
12
|
+
orientation?: 'up' | 'down' | 'left' | 'right'
|
|
13
|
+
}) {
|
|
14
|
+
const d = {
|
|
15
|
+
left: 'm15 18-6-6 6-6',
|
|
16
|
+
right: 'm9 18 6-6-6-6',
|
|
17
|
+
up: 'm18 15-6-6-6 6',
|
|
18
|
+
down: 'm6 9 6 6 6-6',
|
|
19
|
+
}[orientation]
|
|
20
|
+
return (
|
|
21
|
+
<svg
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
fill="none"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
strokeWidth="2"
|
|
26
|
+
strokeLinecap="round"
|
|
27
|
+
strokeLinejoin="round"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
className={className}
|
|
30
|
+
>
|
|
31
|
+
<path d={d} />
|
|
32
|
+
</svg>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A month grid for picking dates, built on react-day-picker and styled by the theme.
|
|
38
|
+
* Supports `mode="single" | "multiple" | "range"`, disabled dates and several months.
|
|
39
|
+
*/
|
|
40
|
+
export function Calendar(inProps: CalendarProps) {
|
|
41
|
+
const [props, slots] = useComponentConfig('Calendar', inProps)
|
|
42
|
+
const { className, classNames, components, showOutsideDays = true, ...rest } = props
|
|
43
|
+
const s = calendarStyles()
|
|
44
|
+
return (
|
|
45
|
+
<DayPicker
|
|
46
|
+
showOutsideDays={showOutsideDays}
|
|
47
|
+
className={s.root({ class: [slots.root, className] })}
|
|
48
|
+
classNames={{
|
|
49
|
+
months: s.months({ class: slots.months }),
|
|
50
|
+
month: s.month({ class: slots.month }),
|
|
51
|
+
month_caption: s.monthCaption({ class: slots.monthCaption }),
|
|
52
|
+
caption_label: s.captionLabel({ class: slots.captionLabel }),
|
|
53
|
+
nav: s.nav({ class: slots.nav }),
|
|
54
|
+
button_previous: s.navButton({ class: slots.navButton }),
|
|
55
|
+
button_next: s.navButton({ class: slots.navButton }),
|
|
56
|
+
chevron: s.chevron({ class: slots.chevron }),
|
|
57
|
+
month_grid: s.monthGrid({ class: slots.monthGrid }),
|
|
58
|
+
weekdays: s.weekdays({ class: slots.weekdays }),
|
|
59
|
+
weekday: s.weekday({ class: slots.weekday }),
|
|
60
|
+
weeks: s.weeks({ class: slots.weeks }),
|
|
61
|
+
week: s.week({ class: slots.week }),
|
|
62
|
+
day: s.day({ class: slots.day }),
|
|
63
|
+
day_button: s.dayButton({ class: slots.dayButton }),
|
|
64
|
+
today: s.today({ class: slots.today }),
|
|
65
|
+
selected: s.selected({ class: slots.selected }),
|
|
66
|
+
outside: s.outside({ class: slots.outside }),
|
|
67
|
+
disabled: s.disabled({ class: slots.disabled }),
|
|
68
|
+
range_start: s.rangeStart({ class: slots.rangeStart }),
|
|
69
|
+
range_middle: s.rangeMiddle({ class: slots.rangeMiddle }),
|
|
70
|
+
range_end: s.rangeEnd({ class: slots.rangeEnd }),
|
|
71
|
+
hidden: s.hidden({ class: slots.hidden }),
|
|
72
|
+
footer: s.footer({ class: slots.footer }),
|
|
73
|
+
...classNames,
|
|
74
|
+
}}
|
|
75
|
+
components={{
|
|
76
|
+
Chevron: ({ className: chevronClass, orientation }) => (
|
|
77
|
+
<ChevronIcon className={chevronClass} orientation={orientation} />
|
|
78
|
+
),
|
|
79
|
+
...components,
|
|
80
|
+
}}
|
|
81
|
+
{...(rest as DayPickerProps)}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
2
|
+
import { focusRing, transition } from '../../utils/styles'
|
|
3
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
4
|
+
|
|
5
|
+
export const carouselStyles = tv({
|
|
6
|
+
slots: {
|
|
7
|
+
root: 'relative',
|
|
8
|
+
viewport: 'overflow-hidden rounded-lg',
|
|
9
|
+
container: 'flex',
|
|
10
|
+
item: 'min-w-0 shrink-0 grow-0 basis-full',
|
|
11
|
+
control: 'absolute z-10 border-border bg-surface/85 shadow-sm backdrop-blur disabled:opacity-0',
|
|
12
|
+
dots: 'mt-3 flex items-center justify-center gap-1.5',
|
|
13
|
+
dot: [
|
|
14
|
+
'h-2 w-2 cursor-pointer rounded-pill bg-border-strong',
|
|
15
|
+
transition,
|
|
16
|
+
focusRing,
|
|
17
|
+
'hover:bg-muted-foreground aria-[current=true]:w-5 aria-[current=true]:bg-primary',
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
variants: {
|
|
21
|
+
orientation: {
|
|
22
|
+
horizontal: {
|
|
23
|
+
container: '-ml-4',
|
|
24
|
+
item: 'pl-4',
|
|
25
|
+
control: 'top-1/2 -translate-y-1/2',
|
|
26
|
+
},
|
|
27
|
+
vertical: {
|
|
28
|
+
container: '-mt-4 h-full flex-col',
|
|
29
|
+
item: 'pt-4',
|
|
30
|
+
control: 'left-1/2 -translate-x-1/2 [&_svg]:rotate-90',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: { orientation: 'horizontal' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export type CarouselVariants = VariantProps<typeof carouselStyles>
|
|
38
|
+
|
|
39
|
+
declare module '@glyphui/tokens' {
|
|
40
|
+
interface GlyphComponentRegistry {
|
|
41
|
+
Carousel: StylesEntry<typeof carouselStyles>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
type ComponentProps,
|
|
5
|
+
createContext,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
useCallback,
|
|
9
|
+
useContext,
|
|
10
|
+
useEffect,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import { type SlotClasses, useComponentConfig } from '../../hooks/use-component-config'
|
|
14
|
+
import { cn } from '../../utils/cn'
|
|
15
|
+
import { IconButton, type IconButtonProps } from '../button'
|
|
16
|
+
import { type CarouselVariants, carouselStyles } from './carousel.styles'
|
|
17
|
+
|
|
18
|
+
export type CarouselApi = UseEmblaCarouselType[1]
|
|
19
|
+
type EmblaArgs = Parameters<typeof useEmblaCarousel>
|
|
20
|
+
export type CarouselOptions = EmblaArgs[0]
|
|
21
|
+
export type CarouselPlugin = NonNullable<EmblaArgs[1]>[number]
|
|
22
|
+
|
|
23
|
+
type Orientation = NonNullable<CarouselVariants['orientation']>
|
|
24
|
+
|
|
25
|
+
interface CarouselContextValue {
|
|
26
|
+
viewportRef: UseEmblaCarouselType[0]
|
|
27
|
+
api: CarouselApi
|
|
28
|
+
orientation: Orientation
|
|
29
|
+
selectedIndex: number
|
|
30
|
+
snapCount: number
|
|
31
|
+
canScrollPrev: boolean
|
|
32
|
+
canScrollNext: boolean
|
|
33
|
+
scrollPrev: () => void
|
|
34
|
+
scrollNext: () => void
|
|
35
|
+
scrollTo: (index: number) => void
|
|
36
|
+
s: ReturnType<typeof carouselStyles>
|
|
37
|
+
slots: SlotClasses<'Carousel'>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CarouselContext = createContext<CarouselContextValue | null>(null)
|
|
41
|
+
|
|
42
|
+
function useCarousel(): CarouselContextValue {
|
|
43
|
+
const value = useContext(CarouselContext)
|
|
44
|
+
if (!value) throw new Error('Carousel parts must be used inside <Carousel>.')
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CarouselProps
|
|
49
|
+
extends Omit<ComponentProps<'section'>, 'aria-label'>,
|
|
50
|
+
CarouselVariants {
|
|
51
|
+
/** Embla options, e.g. `{ align: 'start', loop: true }`. */
|
|
52
|
+
opts?: CarouselOptions
|
|
53
|
+
plugins?: CarouselPlugin[]
|
|
54
|
+
/** Shortcut for `opts.loop`. */
|
|
55
|
+
loop?: boolean
|
|
56
|
+
/** Receives the Embla API for custom controls. */
|
|
57
|
+
setApi?: (api: CarouselApi) => void
|
|
58
|
+
/** Required: describes the content, e.g. "Customer stories". */
|
|
59
|
+
'aria-label': string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Swipeable, keyboard-accessible slides. Compose with CarouselContent, CarouselItem,
|
|
64
|
+
* CarouselPrevious, CarouselNext and CarouselDots.
|
|
65
|
+
*/
|
|
66
|
+
export function Carousel(inProps: CarouselProps) {
|
|
67
|
+
const [props, slots] = useComponentConfig('Carousel', inProps)
|
|
68
|
+
const {
|
|
69
|
+
opts,
|
|
70
|
+
plugins,
|
|
71
|
+
loop,
|
|
72
|
+
setApi,
|
|
73
|
+
orientation = 'horizontal',
|
|
74
|
+
className,
|
|
75
|
+
children,
|
|
76
|
+
...rest
|
|
77
|
+
} = props
|
|
78
|
+
const [viewportRef, api] = useEmblaCarousel(
|
|
79
|
+
{
|
|
80
|
+
...opts,
|
|
81
|
+
...(loop !== undefined && { loop }),
|
|
82
|
+
axis: orientation === 'horizontal' ? 'x' : 'y',
|
|
83
|
+
},
|
|
84
|
+
plugins,
|
|
85
|
+
)
|
|
86
|
+
const [state, setState] = useState({
|
|
87
|
+
selectedIndex: 0,
|
|
88
|
+
snapCount: 0,
|
|
89
|
+
canScrollPrev: false,
|
|
90
|
+
canScrollNext: false,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const sync = useCallback((embla: NonNullable<CarouselApi>) => {
|
|
94
|
+
setState({
|
|
95
|
+
selectedIndex: embla.selectedScrollSnap(),
|
|
96
|
+
snapCount: embla.scrollSnapList().length,
|
|
97
|
+
canScrollPrev: embla.canScrollPrev(),
|
|
98
|
+
canScrollNext: embla.canScrollNext(),
|
|
99
|
+
})
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!api) return
|
|
104
|
+
setApi?.(api)
|
|
105
|
+
sync(api)
|
|
106
|
+
api.on('select', sync).on('reInit', sync)
|
|
107
|
+
return () => {
|
|
108
|
+
api.off('select', sync).off('reInit', sync)
|
|
109
|
+
}
|
|
110
|
+
}, [api, setApi, sync])
|
|
111
|
+
|
|
112
|
+
const scrollPrev = useCallback(() => api?.scrollPrev(), [api])
|
|
113
|
+
const scrollNext = useCallback(() => api?.scrollNext(), [api])
|
|
114
|
+
const scrollTo = useCallback((index: number) => api?.scrollTo(index), [api])
|
|
115
|
+
|
|
116
|
+
const onKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
|
|
117
|
+
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
|
|
118
|
+
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
|
|
119
|
+
if (event.key === prevKey) {
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
scrollPrev()
|
|
122
|
+
} else if (event.key === nextKey) {
|
|
123
|
+
event.preventDefault()
|
|
124
|
+
scrollNext()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const s = carouselStyles({ orientation })
|
|
129
|
+
return (
|
|
130
|
+
<CarouselContext.Provider
|
|
131
|
+
value={{
|
|
132
|
+
viewportRef,
|
|
133
|
+
api,
|
|
134
|
+
orientation,
|
|
135
|
+
...state,
|
|
136
|
+
scrollPrev,
|
|
137
|
+
scrollNext,
|
|
138
|
+
scrollTo,
|
|
139
|
+
s,
|
|
140
|
+
slots,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: a labelled section is a region, which supports aria-roledescription */}
|
|
144
|
+
<section
|
|
145
|
+
data-slot="carousel"
|
|
146
|
+
aria-roledescription="carousel"
|
|
147
|
+
onKeyDownCapture={onKeyDownCapture}
|
|
148
|
+
className={s.root({ class: [slots.root, className] })}
|
|
149
|
+
{...rest}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</section>
|
|
153
|
+
</CarouselContext.Provider>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const SlideContext = createContext<{ index: number; total: number } | null>(null)
|
|
158
|
+
|
|
159
|
+
export function CarouselContent({ className, children, ...props }: ComponentProps<'div'>) {
|
|
160
|
+
const { viewportRef, s, slots } = useCarousel()
|
|
161
|
+
const items = Children.toArray(children)
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
ref={viewportRef}
|
|
165
|
+
data-slot="carousel-viewport"
|
|
166
|
+
className={s.viewport({ class: slots.viewport })}
|
|
167
|
+
>
|
|
168
|
+
<div
|
|
169
|
+
data-slot="carousel-content"
|
|
170
|
+
className={s.container({ class: [slots.container, className] })}
|
|
171
|
+
{...props}
|
|
172
|
+
>
|
|
173
|
+
{items.map((child, index) => (
|
|
174
|
+
<SlideContext.Provider key={index} value={{ index, total: items.length }}>
|
|
175
|
+
{child}
|
|
176
|
+
</SlideContext.Provider>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function CarouselItem({ className, ...props }: ComponentProps<'div'>) {
|
|
184
|
+
const { s, slots } = useCarousel()
|
|
185
|
+
const slide = useContext(SlideContext)
|
|
186
|
+
return (
|
|
187
|
+
// biome-ignore lint/a11y/useSemanticElements: role="group" + aria-roledescription is the WAI-ARIA carousel slide pattern
|
|
188
|
+
<div
|
|
189
|
+
role="group"
|
|
190
|
+
aria-roledescription="slide"
|
|
191
|
+
aria-label={slide ? `${slide.index + 1} of ${slide.total}` : undefined}
|
|
192
|
+
data-slot="carousel-item"
|
|
193
|
+
className={s.item({ class: [slots.item, className] })}
|
|
194
|
+
{...props}
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function Chevron({ direction }: { direction: 'left' | 'right' }) {
|
|
200
|
+
return (
|
|
201
|
+
<svg
|
|
202
|
+
viewBox="0 0 24 24"
|
|
203
|
+
fill="none"
|
|
204
|
+
stroke="currentColor"
|
|
205
|
+
strokeWidth="2"
|
|
206
|
+
strokeLinecap="round"
|
|
207
|
+
strokeLinejoin="round"
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
>
|
|
210
|
+
<path d={direction === 'left' ? 'm15 18-6-6 6-6' : 'm9 18 6-6-6-6'} />
|
|
211
|
+
</svg>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
type ControlProps = Omit<IconButtonProps, 'icon' | 'aria-label'> & {
|
|
216
|
+
'aria-label'?: string
|
|
217
|
+
icon?: ReactNode
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function CarouselPrevious({ className, icon, ...props }: ControlProps) {
|
|
221
|
+
const { scrollPrev, canScrollPrev, orientation, s, slots } = useCarousel()
|
|
222
|
+
return (
|
|
223
|
+
<IconButton
|
|
224
|
+
variant="outline"
|
|
225
|
+
size="sm"
|
|
226
|
+
aria-label="Previous slide"
|
|
227
|
+
icon={icon ?? <Chevron direction="left" />}
|
|
228
|
+
disabled={!canScrollPrev}
|
|
229
|
+
onClick={scrollPrev}
|
|
230
|
+
className={s.control({
|
|
231
|
+
class: [slots.control, orientation === 'horizontal' ? 'left-3' : 'top-3', className],
|
|
232
|
+
})}
|
|
233
|
+
{...props}
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function CarouselNext({ className, icon, ...props }: ControlProps) {
|
|
239
|
+
const { scrollNext, canScrollNext, orientation, s, slots } = useCarousel()
|
|
240
|
+
return (
|
|
241
|
+
<IconButton
|
|
242
|
+
variant="outline"
|
|
243
|
+
size="sm"
|
|
244
|
+
aria-label="Next slide"
|
|
245
|
+
icon={icon ?? <Chevron direction="right" />}
|
|
246
|
+
disabled={!canScrollNext}
|
|
247
|
+
onClick={scrollNext}
|
|
248
|
+
className={s.control({
|
|
249
|
+
class: [slots.control, orientation === 'horizontal' ? 'right-3' : 'bottom-3', className],
|
|
250
|
+
})}
|
|
251
|
+
{...props}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One button per snap point; the current one is highlighted. */
|
|
257
|
+
export function CarouselDots({ className, ...props }: ComponentProps<'div'>) {
|
|
258
|
+
const { snapCount, selectedIndex, scrollTo, s, slots } = useCarousel()
|
|
259
|
+
if (snapCount < 2) return null
|
|
260
|
+
return (
|
|
261
|
+
<div
|
|
262
|
+
data-slot="carousel-dots"
|
|
263
|
+
className={s.dots({ class: [slots.dots, className] })}
|
|
264
|
+
{...props}
|
|
265
|
+
>
|
|
266
|
+
{Array.from({ length: snapCount }, (_, index) => (
|
|
267
|
+
<button
|
|
268
|
+
key={index}
|
|
269
|
+
type="button"
|
|
270
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
271
|
+
aria-current={index === selectedIndex}
|
|
272
|
+
onClick={() => scrollTo(index)}
|
|
273
|
+
className={cn(s.dot({ class: slots.dot }))}
|
|
274
|
+
/>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Carousel,
|
|
3
|
+
type CarouselApi,
|
|
4
|
+
CarouselContent,
|
|
5
|
+
CarouselDots,
|
|
6
|
+
CarouselItem,
|
|
7
|
+
CarouselNext,
|
|
8
|
+
type CarouselOptions,
|
|
9
|
+
type CarouselPlugin,
|
|
10
|
+
CarouselPrevious,
|
|
11
|
+
type CarouselProps,
|
|
12
|
+
} from './carousel'
|
|
13
|
+
export { type CarouselVariants, carouselStyles } from './carousel.styles'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Collapsible as CollapsiblePrimitive } from 'radix-ui'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
3
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
4
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
5
|
+
import { tv } from '../../utils/tv'
|
|
6
|
+
|
|
7
|
+
export const collapsibleStyles = tv({
|
|
8
|
+
slots: {
|
|
9
|
+
root: '',
|
|
10
|
+
trigger: '',
|
|
11
|
+
content:
|
|
12
|
+
'overflow-hidden data-[state=closed]:animate-collapse-up data-[state=open]:animate-collapse-down',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
declare module '@glyphui/tokens' {
|
|
17
|
+
interface GlyphComponentRegistry {
|
|
18
|
+
Collapsible: StylesEntry<typeof collapsibleStyles>
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const s = collapsibleStyles()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shows and hides one region, e.g. "Show advanced options". For a list of expandable
|
|
26
|
+
* sections use Accordion.
|
|
27
|
+
*/
|
|
28
|
+
export function Collapsible(inProps: ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
|
29
|
+
const [{ className, ...rest }, slots] = useComponentConfig('Collapsible', inProps)
|
|
30
|
+
return (
|
|
31
|
+
<CollapsiblePrimitive.Root
|
|
32
|
+
data-slot="collapsible"
|
|
33
|
+
className={s.root({ class: [slots.root, className] })}
|
|
34
|
+
{...rest}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CollapsibleTrigger({
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
}: ComponentProps<typeof CollapsiblePrimitive.Trigger>) {
|
|
43
|
+
const [, slots] = useComponentConfig('Collapsible', {})
|
|
44
|
+
return (
|
|
45
|
+
<CollapsiblePrimitive.Trigger
|
|
46
|
+
data-slot="collapsible-trigger"
|
|
47
|
+
className={s.trigger({ class: [slots.trigger, className] })}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Animates its height open and closed; stays mounted until the closing animation ends. */
|
|
54
|
+
export function CollapsibleContent({
|
|
55
|
+
className,
|
|
56
|
+
...props
|
|
57
|
+
}: ComponentProps<typeof CollapsiblePrimitive.Content>) {
|
|
58
|
+
const [, slots] = useComponentConfig('Collapsible', {})
|
|
59
|
+
return (
|
|
60
|
+
<CollapsiblePrimitive.Content
|
|
61
|
+
data-slot="collapsible-content"
|
|
62
|
+
className={s.content({ class: [slots.content, className] })}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
}
|