@toolpath/ui 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.d.ts +1174 -0
- package/dist/index.js +5934 -0
- package/package.json +68 -0
- package/src/attribution/action.tsx +13 -0
- package/src/attribution/at.tsx +28 -0
- package/src/attribution/attribution.tsx +16 -0
- package/src/attribution/by.tsx +21 -0
- package/src/attribution/format-time.ts +48 -0
- package/src/attribution/index.ts +20 -0
- package/src/badge/badge.tsx +38 -0
- package/src/badge/index.ts +1 -0
- package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
- package/src/breadcrumbs/breadcrumbs.tsx +24 -0
- package/src/breadcrumbs/index.ts +12 -0
- package/src/breadcrumbs/item.tsx +85 -0
- package/src/button/animated-ellipsis.tsx +44 -0
- package/src/button/button.tsx +168 -0
- package/src/button/index.ts +1 -0
- package/src/callout/callout.tsx +202 -0
- package/src/callout/index.ts +2 -0
- package/src/callout/use-dismissed-callouts.ts +78 -0
- package/src/card/card.tsx +19 -0
- package/src/card/index.ts +2 -0
- package/src/checkbox/checkbox.tsx +76 -0
- package/src/checkbox/index.ts +1 -0
- package/src/combobox/button.tsx +99 -0
- package/src/combobox/combobox-context.tsx +42 -0
- package/src/combobox/combobox.tsx +60 -0
- package/src/combobox/empty.tsx +21 -0
- package/src/combobox/group-label.tsx +20 -0
- package/src/combobox/group.tsx +11 -0
- package/src/combobox/icon.tsx +11 -0
- package/src/combobox/index.tsx +64 -0
- package/src/combobox/input.tsx +77 -0
- package/src/combobox/item-indicator.tsx +16 -0
- package/src/combobox/item.tsx +58 -0
- package/src/combobox/list.tsx +19 -0
- package/src/combobox/popover.tsx +62 -0
- package/src/combobox/trigger.tsx +33 -0
- package/src/combobox/value.tsx +12 -0
- package/src/common/index.ts +1 -0
- package/src/common/svg.tsx +17 -0
- package/src/dialog/actions.tsx +17 -0
- package/src/dialog/confirm-button.tsx +25 -0
- package/src/dialog/dialog-context.tsx +21 -0
- package/src/dialog/dialog.tsx +37 -0
- package/src/dialog/dismiss-button.tsx +29 -0
- package/src/dialog/index.ts +37 -0
- package/src/dialog/message.tsx +20 -0
- package/src/dialog/provider.tsx +102 -0
- package/src/dialog/title.tsx +20 -0
- package/src/editable-cell/editable-cell.tsx +307 -0
- package/src/editable-cell/index.ts +7 -0
- package/src/editable-cell/use-editable-grid.tsx +296 -0
- package/src/field/field.tsx +22 -0
- package/src/field/index.ts +2 -0
- package/src/helpers/cn.ts +6 -0
- package/src/helpers/index.ts +1 -0
- package/src/icon-button/icon-button.tsx +126 -0
- package/src/icon-button/index.ts +2 -0
- package/src/index.ts +32 -0
- package/src/input/index.ts +1 -0
- package/src/input/input.tsx +276 -0
- package/src/link/index.ts +4 -0
- package/src/link/link-context.tsx +33 -0
- package/src/link/link.tsx +24 -0
- package/src/menu/divider.tsx +17 -0
- package/src/menu/index.ts +40 -0
- package/src/menu/item.tsx +149 -0
- package/src/menu/menu-context.tsx +24 -0
- package/src/menu/menu.tsx +14 -0
- package/src/menu/popover.tsx +69 -0
- package/src/menu/submenu-trigger.tsx +48 -0
- package/src/menu/submenu.tsx +20 -0
- package/src/menu/trigger.tsx +43 -0
- package/src/notification/index.ts +16 -0
- package/src/notification/list.tsx +123 -0
- package/src/notification/provider.tsx +75 -0
- package/src/page-header/index.ts +2 -0
- package/src/page-header/page-header.tsx +14 -0
- package/src/pagination/index.ts +1 -0
- package/src/pagination/pagination.tsx +136 -0
- package/src/panels/collapsed.tsx +32 -0
- package/src/panels/group.tsx +27 -0
- package/src/panels/index.ts +57 -0
- package/src/panels/layout-context.ts +5 -0
- package/src/panels/orientation-context.tsx +5 -0
- package/src/panels/panel.tsx +95 -0
- package/src/panels/separator.tsx +44 -0
- package/src/pin/index.ts +1 -0
- package/src/pin/pin.tsx +99 -0
- package/src/progress-bar/index.ts +1 -0
- package/src/progress-bar/progress-bar.tsx +48 -0
- package/src/quantity-input/index.ts +1 -0
- package/src/quantity-input/quantity-input.tsx +158 -0
- package/src/scroll-area/index.ts +1 -0
- package/src/scroll-area/scroll-area.tsx +82 -0
- package/src/slider/index.ts +1 -0
- package/src/slider/slider.tsx +75 -0
- package/src/star/index.ts +1 -0
- package/src/star/star.tsx +99 -0
- package/src/switch/index.ts +1 -0
- package/src/switch/switch.tsx +33 -0
- package/src/table/body.tsx +15 -0
- package/src/table/cell-context.tsx +48 -0
- package/src/table/cell.tsx +83 -0
- package/src/table/group-empty-state.tsx +28 -0
- package/src/table/group-header.tsx +62 -0
- package/src/table/header-cell.tsx +158 -0
- package/src/table/header-row.tsx +28 -0
- package/src/table/index.ts +56 -0
- package/src/table/is-group-empty-state.ts +17 -0
- package/src/table/is-group-header.ts +5 -0
- package/src/table/row-context.tsx +61 -0
- package/src/table/row-disabled-context.tsx +7 -0
- package/src/table/row.tsx +98 -0
- package/src/table/table-context.tsx +127 -0
- package/src/table/table-empty.tsx +57 -0
- package/src/table/table.tsx +547 -0
- package/src/table/tree-icon.tsx +147 -0
- package/src/table/use-checkbox.ts +200 -0
- package/src/table/use-column-layout.ts +117 -0
- package/src/table/use-groups.ts +134 -0
- package/src/table/use-keyboard-navigation.ts +274 -0
- package/src/table/use-sort-functions.ts +90 -0
- package/src/tabs/index.ts +20 -0
- package/src/tabs/list.tsx +29 -0
- package/src/tabs/tab.tsx +141 -0
- package/src/tabs/tabs-context.tsx +38 -0
- package/src/tabs/tabs.tsx +27 -0
- package/src/textarea/index.ts +1 -0
- package/src/textarea/textarea.tsx +88 -0
- package/src/toast/index.ts +19 -0
- package/src/toast/list.tsx +133 -0
- package/src/toast/provider.tsx +36 -0
- package/src/toggle/index.ts +17 -0
- package/src/toggle/indicator.tsx +27 -0
- package/src/toggle/item.tsx +54 -0
- package/src/toggle/toggle-context.tsx +68 -0
- package/src/toggle/toggle.tsx +124 -0
- package/src/toggle/use-indicator.tsx +69 -0
- package/src/tooltip/index.ts +1 -0
- package/src/tooltip/tooltip.tsx +122 -0
- package/theme.css +70 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { ChangeEvent, TextareaHTMLAttributes, forwardRef } from 'react'
|
|
2
|
+
import { cn } from '../helpers'
|
|
3
|
+
|
|
4
|
+
export type TextAreaProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'> & {
|
|
5
|
+
name: string
|
|
6
|
+
id: string
|
|
7
|
+
size?: 'md' | 'lg' | 'xl'
|
|
8
|
+
variant?: 'default' | 'secondary' | 'muted'
|
|
9
|
+
invalid?: boolean
|
|
10
|
+
onValueChange?: (value: string) => void
|
|
11
|
+
textAreaClassName?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
|
15
|
+
(
|
|
16
|
+
{
|
|
17
|
+
name,
|
|
18
|
+
id,
|
|
19
|
+
value,
|
|
20
|
+
rows = 3,
|
|
21
|
+
className,
|
|
22
|
+
textAreaClassName,
|
|
23
|
+
disabled = false,
|
|
24
|
+
variant = 'default',
|
|
25
|
+
size = 'lg',
|
|
26
|
+
invalid = false,
|
|
27
|
+
onValueChange,
|
|
28
|
+
...props
|
|
29
|
+
},
|
|
30
|
+
ref,
|
|
31
|
+
) => {
|
|
32
|
+
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
33
|
+
onValueChange?.(event.target.value)
|
|
34
|
+
props.onChange?.(event)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
'relative flex w-full rounded-md font-medium',
|
|
41
|
+
{
|
|
42
|
+
'focus-within:ring-2 ring-info/50 dark:ring-info/75': !invalid,
|
|
43
|
+
'text-gray-400 dark:text-zinc-200 border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 focus-within:border-info dark:focus-within:border-info':
|
|
44
|
+
variant === 'secondary',
|
|
45
|
+
'bg-gray-50 dark:bg-zinc-800 text-gray-500 dark:text-zinc-100': variant === 'default',
|
|
46
|
+
'bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-zinc-100':
|
|
47
|
+
variant === 'muted' || disabled,
|
|
48
|
+
'bg-gray-200 dark:bg-zinc-800': variant === 'muted' && disabled,
|
|
49
|
+
'border border-[#FFD4E0] dark:border-danger/50 bg-[#FFD4E0]/25 dark:bg-danger/25 focus-within:border-[#FFD4E0] dark:focus-within:border-danger focus-within:ring-2 ring-[#FFD4E0] dark:ring-danger/50':
|
|
50
|
+
invalid && !disabled,
|
|
51
|
+
},
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
data-disabled={disabled || undefined}
|
|
55
|
+
data-invalid={invalid || undefined}
|
|
56
|
+
>
|
|
57
|
+
<textarea
|
|
58
|
+
ref={ref}
|
|
59
|
+
id={id}
|
|
60
|
+
name={name}
|
|
61
|
+
data-testid={id}
|
|
62
|
+
data-textarea
|
|
63
|
+
value={value}
|
|
64
|
+
rows={rows}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
onChange={handleChange}
|
|
67
|
+
className={cn(
|
|
68
|
+
'w-full bg-transparent leading-normal outline-none',
|
|
69
|
+
'resize-y',
|
|
70
|
+
{
|
|
71
|
+
'cursor-not-allowed select-none': disabled,
|
|
72
|
+
'placeholder-gray-200 dark:placeholder-zinc-400': variant === 'secondary',
|
|
73
|
+
'placeholder-gray-300 dark:placeholder-zinc-400':
|
|
74
|
+
variant === 'default' || variant === 'muted',
|
|
75
|
+
'px-1 py-1 text-xs': size === 'md',
|
|
76
|
+
'px-2 py-1.5 text-sm': size === 'lg',
|
|
77
|
+
'px-2 py-2 text-base': size === 'xl',
|
|
78
|
+
},
|
|
79
|
+
textAreaClassName,
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
TextArea.displayName = 'TextArea'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { UseToastManagerReturnValue } from '@base-ui/react'
|
|
2
|
+
import { CustomToastData, List } from './list'
|
|
3
|
+
import { Provider, ToastProviderProps, useToastManager } from './provider'
|
|
4
|
+
|
|
5
|
+
type ToastCompoundType = {
|
|
6
|
+
List: typeof List
|
|
7
|
+
Provider: typeof Provider
|
|
8
|
+
useToastManager: typeof useToastManager
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Toast: ToastCompoundType = Object.assign({
|
|
12
|
+
List,
|
|
13
|
+
Provider,
|
|
14
|
+
useToastManager,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
type ToastManager = UseToastManagerReturnValue
|
|
18
|
+
|
|
19
|
+
export type { ToastProviderProps, ToastManager, CustomToastData }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react'
|
|
2
|
+
import { Toast } from '@base-ui/react'
|
|
3
|
+
import { Button } from '../button'
|
|
4
|
+
import { cn } from '../helpers'
|
|
5
|
+
import { IconButton } from '../icon-button'
|
|
6
|
+
import { SpinnerGapIcon, XIcon } from '@phosphor-icons/react'
|
|
7
|
+
import { useToastManager } from './provider'
|
|
8
|
+
|
|
9
|
+
export interface CustomToastData {
|
|
10
|
+
content: ReactNode
|
|
11
|
+
/** When true, the toast cannot be dismissed by swiping. Only programmatic close is allowed. */
|
|
12
|
+
persistent?: boolean
|
|
13
|
+
/** Optional classes applied to the toast shell. */
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const List = () => {
|
|
18
|
+
// Use the separate toast context - all toasts here are regular toasts
|
|
19
|
+
const { toasts: regularToasts } = useToastManager()
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Toast.Portal data-foo>
|
|
23
|
+
<Toast.Viewport
|
|
24
|
+
className={cn(
|
|
25
|
+
'font-body text-base font-medium tracking-normal fixed bottom-6 right-6 z-[100]',
|
|
26
|
+
{
|
|
27
|
+
'flex w-full max-w-sm flex-col pt-8': regularToasts.length,
|
|
28
|
+
'pointer-events-none': !regularToasts.length,
|
|
29
|
+
},
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{regularToasts.map((toast) => {
|
|
33
|
+
const isLoading = toast?.type === 'loading'
|
|
34
|
+
const customData = toast.data as CustomToastData | undefined
|
|
35
|
+
const customContent = customData?.content
|
|
36
|
+
const persistent = customData?.persistent
|
|
37
|
+
const customClassName = customData?.className
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Toast.Root
|
|
41
|
+
key={toast.id}
|
|
42
|
+
toast={toast}
|
|
43
|
+
tabIndex={-1}
|
|
44
|
+
swipeDirection={persistent ? [] : ['down', 'left', 'right']}
|
|
45
|
+
onKeyDown={
|
|
46
|
+
persistent
|
|
47
|
+
? (e) => {
|
|
48
|
+
if (e.key === 'Escape') {
|
|
49
|
+
e.stopPropagation()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
: undefined
|
|
53
|
+
}
|
|
54
|
+
className={cn(
|
|
55
|
+
// Base styles - white background with border like frontend notification
|
|
56
|
+
'relative flex w-full flex-col rounded-lg border bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 shadow-md outline-none',
|
|
57
|
+
// Animation: transition properties
|
|
58
|
+
'transition-all duration-300 ease-out',
|
|
59
|
+
// Stacking: absolute positioning to overlap (bottom-aligned for upward stacking)
|
|
60
|
+
'absolute bottom-0 left-0 right-0',
|
|
61
|
+
// Stacking: z-index based on --toast-index (lower index = front)
|
|
62
|
+
'[z-index:calc(1000-var(--toast-index))]',
|
|
63
|
+
// Stacking: scale down and offset each toast behind the front one (upward)
|
|
64
|
+
'[transform:scale(calc(1-0.05*var(--toast-index)))_translateY(calc(var(--toast-index)*-12px))_translateX(var(--toast-swipe-movement-x,0px))]',
|
|
65
|
+
// Stacking: reduce brightness for toasts behind
|
|
66
|
+
'[filter:brightness(calc(1-0.08*var(--toast-index)))]',
|
|
67
|
+
// Expanded state: spread out when hovering the viewport (with 8px gap between each, going upward)
|
|
68
|
+
'data-[expanded]:[transform:translateY(calc(-1*var(--toast-offset-y)-var(--toast-index)*8px))_translateX(var(--toast-swipe-movement-x,0px))]',
|
|
69
|
+
'data-[expanded]:[filter:brightness(1)]',
|
|
70
|
+
// Entry animation (starting style) - slide up from bottom
|
|
71
|
+
'data-[starting-style]:[transform:scale(calc(1-0.05*var(--toast-index)))_translateY(150%)]',
|
|
72
|
+
'data-[starting-style]:opacity-0',
|
|
73
|
+
// Exit animation (ending style)
|
|
74
|
+
'data-[ending-style]:opacity-0',
|
|
75
|
+
'data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y,0px)+150%))]',
|
|
76
|
+
'data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x,0px)-150%))]',
|
|
77
|
+
'data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x,0px)+150%))]',
|
|
78
|
+
customClassName,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{customContent ? (
|
|
82
|
+
customContent
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
<div className="flex w-full flex-col gap-1 rounded-lg px-2 py-3">
|
|
86
|
+
{/* Title row with optional spinner */}
|
|
87
|
+
<div className="flex items-center gap-2 px-2">
|
|
88
|
+
{isLoading && (
|
|
89
|
+
<SpinnerGapIcon className="size-4 shrink-0 animate-spin text-gray-300 dark:text-zinc-200" />
|
|
90
|
+
)}
|
|
91
|
+
<Toast.Title className="flex-1 whitespace-pre-line break-words text-sm font-bold text-gray-500 dark:text-zinc-200" />
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Description/message */}
|
|
95
|
+
<Toast.Content className="flex flex-col px-2 transition-opacity duration-300 data-[behind]:opacity-0 data-[expanded]:opacity-100">
|
|
96
|
+
<Toast.Description className="flex-1 whitespace-pre-line break-words text-sm font-medium text-gray-500 dark:text-zinc-200" />
|
|
97
|
+
</Toast.Content>
|
|
98
|
+
|
|
99
|
+
{/* Action button area */}
|
|
100
|
+
<div className="mt-1 flex flex-wrap items-center justify-end gap-2 px-1">
|
|
101
|
+
<Toast.Action
|
|
102
|
+
nativeButton={true}
|
|
103
|
+
render={(renderProps) => (
|
|
104
|
+
<Button className="rounded-lg py-2" variant="success" {...renderProps}>
|
|
105
|
+
{renderProps.children}
|
|
106
|
+
</Button>
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<Toast.Close
|
|
113
|
+
aria-label="Close"
|
|
114
|
+
nativeButton={true}
|
|
115
|
+
render={(renderProps) => {
|
|
116
|
+
return (
|
|
117
|
+
<div className="absolute right-2 top-2">
|
|
118
|
+
<IconButton size="lg" {...renderProps}>
|
|
119
|
+
<XIcon className="text-gray-300 dark:text-zinc-400" />
|
|
120
|
+
</IconButton>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
</Toast.Root>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
130
|
+
</Toast.Viewport>
|
|
131
|
+
</Toast.Portal>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Toast as BaseToast,
|
|
3
|
+
ToastProviderProps as BaseToastProviderProps,
|
|
4
|
+
UseToastManagerReturnValue,
|
|
5
|
+
} from '@base-ui/react'
|
|
6
|
+
import React, { createContext, ReactNode, useContext } from 'react'
|
|
7
|
+
|
|
8
|
+
// Create a separate context for the toast manager
|
|
9
|
+
// This prevents it from being shadowed by Notification.Provider's internal Toast.Provider
|
|
10
|
+
const ToastContext = createContext<UseToastManagerReturnValue | null>(null)
|
|
11
|
+
|
|
12
|
+
export type ToastProviderProps = BaseToastProviderProps & {
|
|
13
|
+
children?: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Provider = ({ children, ...props }: ToastProviderProps) => (
|
|
17
|
+
<BaseToast.Provider {...props}>
|
|
18
|
+
<ToastContextProvider>{children}</ToastContextProvider>
|
|
19
|
+
</BaseToast.Provider>
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Internal component that captures and provides the toast manager
|
|
23
|
+
const ToastContextProvider = ({ children }: { children?: ReactNode }) => {
|
|
24
|
+
const toastManager = BaseToast.useToastManager()
|
|
25
|
+
return <ToastContext.Provider value={toastManager}>{children}</ToastContext.Provider>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useToastManager = (): UseToastManagerReturnValue => {
|
|
29
|
+
const toastManager = useContext(ToastContext)
|
|
30
|
+
|
|
31
|
+
if (!toastManager) {
|
|
32
|
+
throw new Error('useToastManager must be used within a Toast.Provider')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return toastManager
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ReactElement } from 'react'
|
|
2
|
+
import { Item, ToggleItemProps } from './item'
|
|
3
|
+
import { ToggleProps, Toggle as ToggleRoot } from './toggle'
|
|
4
|
+
import { useToggle } from './toggle-context'
|
|
5
|
+
|
|
6
|
+
type ToggleCompoundType = {
|
|
7
|
+
(props: ToggleProps): ReactElement
|
|
8
|
+
Item: typeof Item
|
|
9
|
+
useToggle: typeof useToggle
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Toggle: ToggleCompoundType = Object.assign(ToggleRoot, {
|
|
13
|
+
Item,
|
|
14
|
+
useToggle,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export type { ToggleProps, ToggleItemProps }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { cn } from '../helpers'
|
|
3
|
+
import { useToggle } from './toggle-context'
|
|
4
|
+
import { useIndicator } from './use-indicator'
|
|
5
|
+
|
|
6
|
+
export const Indicator = () => {
|
|
7
|
+
const { size } = useToggle()
|
|
8
|
+
const { width, left, animationsEnabled } = useIndicator()
|
|
9
|
+
|
|
10
|
+
if (width === null || left === null) {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<span
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
data-indicator
|
|
18
|
+
className={cn('absolute top-0.5 z-0 bg-white dark:bg-zinc-700 shadow-sm', {
|
|
19
|
+
'h-[calc(100%-4px)] rounded-sm': size === 'sm',
|
|
20
|
+
'h-[calc(100%-4px)] rounded': size === 'md',
|
|
21
|
+
'h-[calc(100%-4px)] rounded-md': size === 'lg',
|
|
22
|
+
'transition-[left,width] duration-200 ease-out': animationsEnabled,
|
|
23
|
+
})}
|
|
24
|
+
style={{ width, left }}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React, { ReactNode, useCallback } from 'react'
|
|
2
|
+
import { Toggle as BaseToggle } from '@base-ui/react'
|
|
3
|
+
import { cn } from '../helpers'
|
|
4
|
+
import { useToggle } from './toggle-context'
|
|
5
|
+
|
|
6
|
+
export type ToggleItemProps = {
|
|
7
|
+
value: string
|
|
8
|
+
className?: string
|
|
9
|
+
children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Item = ({ value, className, children }: ToggleItemProps) => {
|
|
13
|
+
const { value: selectedValue, disabled, size, isBinaryToggle, registerItem } = useToggle()
|
|
14
|
+
|
|
15
|
+
const isSelected = value === selectedValue
|
|
16
|
+
|
|
17
|
+
const refCallback = useCallback(
|
|
18
|
+
(el: HTMLButtonElement | null) => {
|
|
19
|
+
registerItem(value, el)
|
|
20
|
+
},
|
|
21
|
+
[registerItem, value],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// For binary toggles, all items are not focusable (container handles focus)
|
|
25
|
+
// For multi-item toggles, selected item is not focusable
|
|
26
|
+
const tabIndex = isBinaryToggle || isSelected ? -1 : undefined
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<BaseToggle
|
|
30
|
+
ref={refCallback}
|
|
31
|
+
value={value}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
tabIndex={tabIndex}
|
|
34
|
+
aria-label={typeof children === 'string' ? children : undefined}
|
|
35
|
+
data-selected={isSelected || undefined}
|
|
36
|
+
data-unselected={!isSelected || undefined}
|
|
37
|
+
className={cn(
|
|
38
|
+
'relative z-10 inline-flex items-center justify-center font-semibold',
|
|
39
|
+
'outline-none transition-colors duration-150 whitespace-nowrap',
|
|
40
|
+
'text-gray-400 dark:text-zinc-400 data-[pressed]:text-gray-600 dark:data-[pressed]:text-zinc-100 dark:hover:text-zinc-100',
|
|
41
|
+
{
|
|
42
|
+
'h-5 px-2 text-3xs rounded-sm': size === 'sm',
|
|
43
|
+
'h-6 px-3 text-2xs rounded': size === 'md',
|
|
44
|
+
'h-8 px-4 text-xs rounded-md': size === 'lg',
|
|
45
|
+
'focus-visible:text-gray-600 dark:focus-visible:text-zinc-400': !isBinaryToggle,
|
|
46
|
+
'cursor-not-allowed': disabled,
|
|
47
|
+
},
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</BaseToggle>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { ReactNode, RefObject, createContext, useCallback, useContext, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ToggleContextValue {
|
|
6
|
+
value: string
|
|
7
|
+
onValueChange: (value: string) => void
|
|
8
|
+
disabled: boolean
|
|
9
|
+
size: 'sm' | 'md' | 'lg'
|
|
10
|
+
isBinaryToggle: boolean
|
|
11
|
+
registerItem: (value: string, el: HTMLButtonElement | null) => void
|
|
12
|
+
containerRef: RefObject<HTMLDivElement | null>
|
|
13
|
+
itemRefs: RefObject<Map<string, HTMLButtonElement>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ToggleContext = createContext<ToggleContextValue | null>(null)
|
|
17
|
+
|
|
18
|
+
export const ToggleConsumer = ToggleContext.Consumer
|
|
19
|
+
|
|
20
|
+
export const useToggle = () => {
|
|
21
|
+
const context = useContext(ToggleContext)
|
|
22
|
+
if (!context) {
|
|
23
|
+
throw new Error('Toggle.Item must be used within a Toggle component')
|
|
24
|
+
}
|
|
25
|
+
return context
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ToggleProviderProps {
|
|
29
|
+
children: ReactNode
|
|
30
|
+
value: string
|
|
31
|
+
onValueChange: (value: string) => void
|
|
32
|
+
disabled: boolean
|
|
33
|
+
size: 'sm' | 'md' | 'lg'
|
|
34
|
+
isBinaryToggle: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const ToggleProvider = ({
|
|
38
|
+
children,
|
|
39
|
+
value,
|
|
40
|
+
onValueChange,
|
|
41
|
+
disabled,
|
|
42
|
+
size,
|
|
43
|
+
isBinaryToggle,
|
|
44
|
+
}: ToggleProviderProps) => {
|
|
45
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
46
|
+
const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
|
|
47
|
+
|
|
48
|
+
const registerItem = useCallback((itemValue: string, el: HTMLButtonElement | null) => {
|
|
49
|
+
if (el) {
|
|
50
|
+
itemRefs.current.set(itemValue, el)
|
|
51
|
+
} else {
|
|
52
|
+
itemRefs.current.delete(itemValue)
|
|
53
|
+
}
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
const contextValue: ToggleContextValue = {
|
|
57
|
+
value,
|
|
58
|
+
onValueChange,
|
|
59
|
+
disabled,
|
|
60
|
+
size,
|
|
61
|
+
isBinaryToggle,
|
|
62
|
+
registerItem,
|
|
63
|
+
containerRef,
|
|
64
|
+
itemRefs,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return <ToggleContext.Provider value={contextValue}>{children}</ToggleContext.Provider>
|
|
68
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { Children, ReactElement, ReactNode, isValidElement } from 'react'
|
|
2
|
+
import { ToggleGroup as BaseToggleGroup } from '@base-ui/react'
|
|
3
|
+
import { cn } from '../helpers'
|
|
4
|
+
import { Indicator } from './indicator'
|
|
5
|
+
import { Item, ToggleItemProps } from './item'
|
|
6
|
+
import { ToggleConsumer, ToggleProvider } from './toggle-context'
|
|
7
|
+
|
|
8
|
+
export type ToggleProps = {
|
|
9
|
+
value: string
|
|
10
|
+
onValueChange: (value: string) => void
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
size?: 'sm' | 'md' | 'lg'
|
|
13
|
+
className?: string
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Toggle = ({
|
|
18
|
+
value,
|
|
19
|
+
onValueChange,
|
|
20
|
+
disabled = false,
|
|
21
|
+
size = 'md',
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
}: ToggleProps) => {
|
|
25
|
+
// Count the number of Toggle.Item children
|
|
26
|
+
const itemCount = Children.toArray(children).filter(
|
|
27
|
+
(child) => isValidElement(child) && child.type === Item,
|
|
28
|
+
).length
|
|
29
|
+
|
|
30
|
+
// Collect item values for binary toggle keyboard handling
|
|
31
|
+
const itemValues = Children.toArray(children)
|
|
32
|
+
.filter((child) => isValidElement(child) && child.type === Item)
|
|
33
|
+
.map((child) => (child as ReactElement<ToggleItemProps>).props.value)
|
|
34
|
+
|
|
35
|
+
const isBinaryToggle = itemCount === 2
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<ToggleProvider
|
|
39
|
+
value={value}
|
|
40
|
+
onValueChange={onValueChange}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
size={size}
|
|
43
|
+
isBinaryToggle={isBinaryToggle}
|
|
44
|
+
>
|
|
45
|
+
<ToggleConsumer>
|
|
46
|
+
{(context) => {
|
|
47
|
+
if (!context) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { containerRef } = context
|
|
52
|
+
|
|
53
|
+
const handleValueChange = (newValues: string[]) => {
|
|
54
|
+
// For binary toggles, clicking either item toggles to the other value
|
|
55
|
+
if (isBinaryToggle) {
|
|
56
|
+
const otherValue = itemValues.find((v) => v !== value)
|
|
57
|
+
if (otherValue) {
|
|
58
|
+
onValueChange(otherValue)
|
|
59
|
+
}
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// For multi-item toggles, only change if clicking a different item
|
|
64
|
+
if (newValues.length > 0 && newValues[0] !== value) {
|
|
65
|
+
onValueChange(newValues[0])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
70
|
+
if (disabled || !isBinaryToggle) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
74
|
+
e.preventDefault()
|
|
75
|
+
const otherValue = itemValues.find((v) => v !== value)
|
|
76
|
+
if (otherValue) {
|
|
77
|
+
onValueChange(otherValue)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build props conditionally based on toggle type
|
|
83
|
+
const accessibilityProps = isBinaryToggle
|
|
84
|
+
? {
|
|
85
|
+
tabIndex: disabled ? undefined : 0,
|
|
86
|
+
onKeyDown: handleKeyDown,
|
|
87
|
+
role: 'switch' as const,
|
|
88
|
+
'aria-checked': value === itemValues[1],
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
role: 'group' as const,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<BaseToggleGroup
|
|
96
|
+
ref={containerRef}
|
|
97
|
+
value={[value]}
|
|
98
|
+
onValueChange={handleValueChange}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
{...accessibilityProps}
|
|
101
|
+
className={cn(
|
|
102
|
+
'relative inline-flex items-center bg-gray-100 dark:bg-zinc-900 px-0.5',
|
|
103
|
+
'outline outline-0.5 outline-offset-0 outline-gray-200 dark:outline-zinc-800',
|
|
104
|
+
{
|
|
105
|
+
'h-5 rounded': size === 'sm',
|
|
106
|
+
'h-6 rounded-md': size === 'md',
|
|
107
|
+
'h-8 rounded-lg': size === 'lg',
|
|
108
|
+
'focus-visible:ring-2 focus-visible:ring-info/75 focus-visible:outline-none':
|
|
109
|
+
isBinaryToggle,
|
|
110
|
+
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-info/75': !isBinaryToggle,
|
|
111
|
+
'cursor-not-allowed opacity-60': disabled,
|
|
112
|
+
},
|
|
113
|
+
className,
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<Indicator />
|
|
117
|
+
{children}
|
|
118
|
+
</BaseToggleGroup>
|
|
119
|
+
)
|
|
120
|
+
}}
|
|
121
|
+
</ToggleConsumer>
|
|
122
|
+
</ToggleProvider>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { useToggle } from './toggle-context'
|
|
5
|
+
|
|
6
|
+
export const useIndicator = () => {
|
|
7
|
+
const { value, containerRef, itemRefs } = useToggle()
|
|
8
|
+
|
|
9
|
+
const [width, setWidth] = useState<number | null>(null)
|
|
10
|
+
const [left, setLeft] = useState<number | null>(null)
|
|
11
|
+
// Track if we've done the first render - use a ref so it doesn't cause re-render
|
|
12
|
+
const hasRenderedOnce = useRef(false)
|
|
13
|
+
// Track if animations should be enabled (after first paint)
|
|
14
|
+
const [animationsEnabled, setAnimationsEnabled] = useState(false)
|
|
15
|
+
|
|
16
|
+
const updateIndicator = useCallback(() => {
|
|
17
|
+
const container = containerRef.current
|
|
18
|
+
const selectedButton = itemRefs.current?.get(value)
|
|
19
|
+
|
|
20
|
+
if (!container || !selectedButton) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Use offsetWidth/offsetLeft instead of getBoundingClientRect
|
|
25
|
+
// to avoid zoom-related sizing issues
|
|
26
|
+
setWidth(selectedButton.offsetWidth)
|
|
27
|
+
setLeft(selectedButton.offsetLeft)
|
|
28
|
+
}, [value, containerRef, itemRefs])
|
|
29
|
+
|
|
30
|
+
// Update indicator when value changes or on mount
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
updateIndicator()
|
|
33
|
+
}, [updateIndicator])
|
|
34
|
+
|
|
35
|
+
// Enable animations only after the first render cycle completes
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (width !== null && left !== null && !hasRenderedOnce.current) {
|
|
38
|
+
hasRenderedOnce.current = true
|
|
39
|
+
// Use requestAnimationFrame to ensure the initial position is painted first
|
|
40
|
+
requestAnimationFrame(() => {
|
|
41
|
+
requestAnimationFrame(() => {
|
|
42
|
+
setAnimationsEnabled(true)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}, [width, left])
|
|
47
|
+
|
|
48
|
+
// Handle resize events
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const observer = new ResizeObserver(() => {
|
|
51
|
+
updateIndicator()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (containerRef.current) {
|
|
55
|
+
observer.observe(containerRef.current)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Also observe each item for content changes
|
|
59
|
+
itemRefs.current?.forEach((button) => {
|
|
60
|
+
observer.observe(button)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
observer.disconnect()
|
|
65
|
+
}
|
|
66
|
+
}, [updateIndicator, containerRef, itemRefs])
|
|
67
|
+
|
|
68
|
+
return { width, left, animationsEnabled }
|
|
69
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './tooltip'
|