@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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/index.d.ts +1174 -0
  4. package/dist/index.js +5934 -0
  5. package/package.json +68 -0
  6. package/src/attribution/action.tsx +13 -0
  7. package/src/attribution/at.tsx +28 -0
  8. package/src/attribution/attribution.tsx +16 -0
  9. package/src/attribution/by.tsx +21 -0
  10. package/src/attribution/format-time.ts +48 -0
  11. package/src/attribution/index.ts +20 -0
  12. package/src/badge/badge.tsx +38 -0
  13. package/src/badge/index.ts +1 -0
  14. package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
  15. package/src/breadcrumbs/breadcrumbs.tsx +24 -0
  16. package/src/breadcrumbs/index.ts +12 -0
  17. package/src/breadcrumbs/item.tsx +85 -0
  18. package/src/button/animated-ellipsis.tsx +44 -0
  19. package/src/button/button.tsx +168 -0
  20. package/src/button/index.ts +1 -0
  21. package/src/callout/callout.tsx +202 -0
  22. package/src/callout/index.ts +2 -0
  23. package/src/callout/use-dismissed-callouts.ts +78 -0
  24. package/src/card/card.tsx +19 -0
  25. package/src/card/index.ts +2 -0
  26. package/src/checkbox/checkbox.tsx +76 -0
  27. package/src/checkbox/index.ts +1 -0
  28. package/src/combobox/button.tsx +99 -0
  29. package/src/combobox/combobox-context.tsx +42 -0
  30. package/src/combobox/combobox.tsx +60 -0
  31. package/src/combobox/empty.tsx +21 -0
  32. package/src/combobox/group-label.tsx +20 -0
  33. package/src/combobox/group.tsx +11 -0
  34. package/src/combobox/icon.tsx +11 -0
  35. package/src/combobox/index.tsx +64 -0
  36. package/src/combobox/input.tsx +77 -0
  37. package/src/combobox/item-indicator.tsx +16 -0
  38. package/src/combobox/item.tsx +58 -0
  39. package/src/combobox/list.tsx +19 -0
  40. package/src/combobox/popover.tsx +62 -0
  41. package/src/combobox/trigger.tsx +33 -0
  42. package/src/combobox/value.tsx +12 -0
  43. package/src/common/index.ts +1 -0
  44. package/src/common/svg.tsx +17 -0
  45. package/src/dialog/actions.tsx +17 -0
  46. package/src/dialog/confirm-button.tsx +25 -0
  47. package/src/dialog/dialog-context.tsx +21 -0
  48. package/src/dialog/dialog.tsx +37 -0
  49. package/src/dialog/dismiss-button.tsx +29 -0
  50. package/src/dialog/index.ts +37 -0
  51. package/src/dialog/message.tsx +20 -0
  52. package/src/dialog/provider.tsx +102 -0
  53. package/src/dialog/title.tsx +20 -0
  54. package/src/editable-cell/editable-cell.tsx +307 -0
  55. package/src/editable-cell/index.ts +7 -0
  56. package/src/editable-cell/use-editable-grid.tsx +296 -0
  57. package/src/field/field.tsx +22 -0
  58. package/src/field/index.ts +2 -0
  59. package/src/helpers/cn.ts +6 -0
  60. package/src/helpers/index.ts +1 -0
  61. package/src/icon-button/icon-button.tsx +126 -0
  62. package/src/icon-button/index.ts +2 -0
  63. package/src/index.ts +32 -0
  64. package/src/input/index.ts +1 -0
  65. package/src/input/input.tsx +276 -0
  66. package/src/link/index.ts +4 -0
  67. package/src/link/link-context.tsx +33 -0
  68. package/src/link/link.tsx +24 -0
  69. package/src/menu/divider.tsx +17 -0
  70. package/src/menu/index.ts +40 -0
  71. package/src/menu/item.tsx +149 -0
  72. package/src/menu/menu-context.tsx +24 -0
  73. package/src/menu/menu.tsx +14 -0
  74. package/src/menu/popover.tsx +69 -0
  75. package/src/menu/submenu-trigger.tsx +48 -0
  76. package/src/menu/submenu.tsx +20 -0
  77. package/src/menu/trigger.tsx +43 -0
  78. package/src/notification/index.ts +16 -0
  79. package/src/notification/list.tsx +123 -0
  80. package/src/notification/provider.tsx +75 -0
  81. package/src/page-header/index.ts +2 -0
  82. package/src/page-header/page-header.tsx +14 -0
  83. package/src/pagination/index.ts +1 -0
  84. package/src/pagination/pagination.tsx +136 -0
  85. package/src/panels/collapsed.tsx +32 -0
  86. package/src/panels/group.tsx +27 -0
  87. package/src/panels/index.ts +57 -0
  88. package/src/panels/layout-context.ts +5 -0
  89. package/src/panels/orientation-context.tsx +5 -0
  90. package/src/panels/panel.tsx +95 -0
  91. package/src/panels/separator.tsx +44 -0
  92. package/src/pin/index.ts +1 -0
  93. package/src/pin/pin.tsx +99 -0
  94. package/src/progress-bar/index.ts +1 -0
  95. package/src/progress-bar/progress-bar.tsx +48 -0
  96. package/src/quantity-input/index.ts +1 -0
  97. package/src/quantity-input/quantity-input.tsx +158 -0
  98. package/src/scroll-area/index.ts +1 -0
  99. package/src/scroll-area/scroll-area.tsx +82 -0
  100. package/src/slider/index.ts +1 -0
  101. package/src/slider/slider.tsx +75 -0
  102. package/src/star/index.ts +1 -0
  103. package/src/star/star.tsx +99 -0
  104. package/src/switch/index.ts +1 -0
  105. package/src/switch/switch.tsx +33 -0
  106. package/src/table/body.tsx +15 -0
  107. package/src/table/cell-context.tsx +48 -0
  108. package/src/table/cell.tsx +83 -0
  109. package/src/table/group-empty-state.tsx +28 -0
  110. package/src/table/group-header.tsx +62 -0
  111. package/src/table/header-cell.tsx +158 -0
  112. package/src/table/header-row.tsx +28 -0
  113. package/src/table/index.ts +56 -0
  114. package/src/table/is-group-empty-state.ts +17 -0
  115. package/src/table/is-group-header.ts +5 -0
  116. package/src/table/row-context.tsx +61 -0
  117. package/src/table/row-disabled-context.tsx +7 -0
  118. package/src/table/row.tsx +98 -0
  119. package/src/table/table-context.tsx +127 -0
  120. package/src/table/table-empty.tsx +57 -0
  121. package/src/table/table.tsx +547 -0
  122. package/src/table/tree-icon.tsx +147 -0
  123. package/src/table/use-checkbox.ts +200 -0
  124. package/src/table/use-column-layout.ts +117 -0
  125. package/src/table/use-groups.ts +134 -0
  126. package/src/table/use-keyboard-navigation.ts +274 -0
  127. package/src/table/use-sort-functions.ts +90 -0
  128. package/src/tabs/index.ts +20 -0
  129. package/src/tabs/list.tsx +29 -0
  130. package/src/tabs/tab.tsx +141 -0
  131. package/src/tabs/tabs-context.tsx +38 -0
  132. package/src/tabs/tabs.tsx +27 -0
  133. package/src/textarea/index.ts +1 -0
  134. package/src/textarea/textarea.tsx +88 -0
  135. package/src/toast/index.ts +19 -0
  136. package/src/toast/list.tsx +133 -0
  137. package/src/toast/provider.tsx +36 -0
  138. package/src/toggle/index.ts +17 -0
  139. package/src/toggle/indicator.tsx +27 -0
  140. package/src/toggle/item.tsx +54 -0
  141. package/src/toggle/toggle-context.tsx +68 -0
  142. package/src/toggle/toggle.tsx +124 -0
  143. package/src/toggle/use-indicator.tsx +69 -0
  144. package/src/tooltip/index.ts +1 -0
  145. package/src/tooltip/tooltip.tsx +122 -0
  146. 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'