@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,274 @@
1
+ import { isNull } from 'lodash-es'
2
+ import { RefObject, useCallback, useEffect, useRef } from 'react'
3
+ import { getRowHeight } from './table'
4
+ import { useTable } from './table-context'
5
+
6
+ export const useKeyboardNavigation = (
7
+ indexes: Array<[number, number | string]>,
8
+ containerRef?: RefObject<HTMLElement | null>,
9
+ data?: Array<any>,
10
+ ) => {
11
+ const {
12
+ cursor,
13
+ setCursor,
14
+ setSelectedRows,
15
+ selectedRows,
16
+ editingCell,
17
+ setEditingCell,
18
+ multiselect,
19
+ keyboardNavigation,
20
+ density,
21
+ onBeforeSelectionChange,
22
+ } = useTable()
23
+
24
+ const rowHeight = getRowHeight(density)
25
+
26
+ // Keep data ref for onBeforeSelectionChange lookups
27
+ const dataRef = useRef(data)
28
+ useEffect(() => {
29
+ dataRef.current = data
30
+ }, [data])
31
+
32
+ // Use refs to access current values in event handlers without re-registering
33
+ const cursorRef = useRef(cursor)
34
+ const indexesRef = useRef(indexes)
35
+ const selectedRowsRef = useRef(selectedRows)
36
+ const multiselectRef = useRef(multiselect)
37
+ const editingCellRef = useRef(editingCell)
38
+ useEffect(() => {
39
+ cursorRef.current = cursor
40
+ }, [cursor])
41
+ useEffect(() => {
42
+ indexesRef.current = indexes
43
+ }, [indexes])
44
+ useEffect(() => {
45
+ selectedRowsRef.current = selectedRows
46
+ }, [selectedRows])
47
+ useEffect(() => {
48
+ multiselectRef.current = multiselect
49
+ }, [multiselect])
50
+ useEffect(() => {
51
+ editingCellRef.current = editingCell
52
+ }, [editingCell])
53
+
54
+ const isFocusWithinContainer = useCallback(() => {
55
+ if (!containerRef?.current) {
56
+ return true // If no container ref provided, allow navigation everywhere (backwards compatible)
57
+ }
58
+ return containerRef.current.contains(document.activeElement)
59
+ }, [containerRef])
60
+
61
+ const scrollRowIntoView = useCallback(
62
+ (rowIndex: number) => {
63
+ if (!containerRef?.current) {
64
+ return
65
+ }
66
+
67
+ // Access the virtualized table's scroll container
68
+ const scrollContainer = containerRef.current.querySelector(
69
+ '[data-table-library_table] > div > div',
70
+ ) as HTMLElement | null
71
+
72
+ if (!scrollContainer) {
73
+ return
74
+ }
75
+
76
+ const scrollTop = scrollContainer.scrollTop
77
+ const containerHeight = scrollContainer.clientHeight
78
+ const rowTop = rowIndex * rowHeight
79
+ const rowBottom = rowTop + rowHeight
80
+
81
+ // Check if row is outside the visible area
82
+ if (rowTop < scrollTop || rowBottom > scrollTop + containerHeight) {
83
+ // Scroll to center the row in the container
84
+ const rowCenter = rowTop + rowHeight / 2
85
+ const targetScrollTop = rowCenter - containerHeight / 2
86
+ scrollContainer.scrollTop = Math.max(0, targetScrollTop)
87
+ }
88
+ // Otherwise row is already visible, don't scroll
89
+ },
90
+ [containerRef, rowHeight],
91
+ )
92
+
93
+ const getNewSelectionState = useCallback((newId: number | string, addToSelection: boolean) => {
94
+ if (!addToSelection || !multiselectRef.current) {
95
+ return { ids: [], id: newId }
96
+ }
97
+
98
+ // When ctrl/cmd is held and multiselect is enabled, preserve existing selections
99
+ const currentSelected = selectedRowsRef.current
100
+ const existingIds: Array<number | string> = []
101
+
102
+ // Collect all currently selected IDs
103
+ if (currentSelected?.id != null) {
104
+ existingIds.push(currentSelected.id)
105
+ }
106
+ if (currentSelected?.ids?.length) {
107
+ existingIds.push(...currentSelected.ids)
108
+ }
109
+
110
+ // Add new ID if not already selected, or toggle it off if it is
111
+ const newIdIndex = existingIds.indexOf(newId)
112
+ if (newIdIndex === -1) {
113
+ existingIds.push(newId)
114
+ }
115
+
116
+ // Deduplicate
117
+ const uniqueIds = Array.from(new Set(existingIds))
118
+ // When using ids for multi-select, id should be null (they're mutually exclusive)
119
+ return { ids: uniqueIds, id: null }
120
+ }, [])
121
+
122
+ // Keep onBeforeSelectionChange ref to avoid re-registering callbacks
123
+ const onBeforeSelectionChangeRef = useRef(onBeforeSelectionChange)
124
+ useEffect(() => {
125
+ onBeforeSelectionChangeRef.current = onBeforeSelectionChange
126
+ }, [onBeforeSelectionChange])
127
+
128
+ // Helper to check if selection change is allowed (e.g., dirty state confirmation)
129
+ // Pass null for newId/newRowIndex when clearing selection (e.g., Escape key)
130
+ const isSelectionChangeAllowed = useCallback(
131
+ (newId: number | string | null, newRowIndex: number | null): boolean => {
132
+ if (!onBeforeSelectionChangeRef.current) {
133
+ return true
134
+ }
135
+ const item = newId != null ? dataRef.current?.find((d) => d.id === newId) : null
136
+ return onBeforeSelectionChangeRef.current(item, newRowIndex) !== false
137
+ },
138
+ [],
139
+ )
140
+
141
+ const increaseCursor = useCallback(
142
+ (addToSelection = false) => {
143
+ const currentIndexes = indexesRef.current
144
+ const currentCursor = cursorRef.current
145
+ if (currentIndexes.length === 0) {
146
+ return
147
+ }
148
+ const currentPosition = currentIndexes.findIndex((item) => item[0] === currentCursor)
149
+ const newPosition = currentPosition + 1 >= currentIndexes.length ? 0 : currentPosition + 1
150
+ const newRowIndex = currentIndexes[newPosition][0]
151
+ const newId = currentIndexes[newPosition][1]
152
+
153
+ if (!isSelectionChangeAllowed(newId, newRowIndex)) {
154
+ return
155
+ }
156
+
157
+ setSelectedRows(getNewSelectionState(newId, addToSelection))
158
+ scrollRowIntoView(newRowIndex)
159
+ },
160
+ [setSelectedRows, scrollRowIntoView, getNewSelectionState, isSelectionChangeAllowed],
161
+ )
162
+
163
+ const decreaseCursor = useCallback(
164
+ (addToSelection = false) => {
165
+ const currentIndexes = indexesRef.current
166
+ const currentCursor = cursorRef.current
167
+ if (currentIndexes.length === 0) {
168
+ return
169
+ }
170
+ const currentPosition = currentIndexes.findIndex((item) => item[0] === currentCursor)
171
+ const newPosition = currentPosition - 1 < 0 ? currentIndexes.length - 1 : currentPosition - 1
172
+ const newRowIndex = currentIndexes[newPosition][0]
173
+ const newId = currentIndexes[newPosition][1]
174
+
175
+ if (!isSelectionChangeAllowed(newId, newRowIndex)) {
176
+ return
177
+ }
178
+
179
+ setSelectedRows(getNewSelectionState(newId, addToSelection))
180
+ scrollRowIntoView(newRowIndex)
181
+ },
182
+ [setSelectedRows, scrollRowIntoView, getNewSelectionState, isSelectionChangeAllowed],
183
+ )
184
+
185
+ // Handle arrow key navigation with preventDefault to stop page scroll
186
+ useEffect(() => {
187
+ if (keyboardNavigation === false) {
188
+ return
189
+ }
190
+ const handleKeyDown = (event: KeyboardEvent) => {
191
+ if (!isFocusWithinContainer() || isNull(cursorRef.current)) {
192
+ return
193
+ }
194
+
195
+ const addToSelection = event.ctrlKey || event.metaKey || event.shiftKey
196
+
197
+ if (event.key === 'ArrowUp') {
198
+ event.preventDefault()
199
+ decreaseCursor(addToSelection)
200
+ } else if (event.key === 'ArrowDown') {
201
+ event.preventDefault()
202
+ increaseCursor(addToSelection)
203
+ }
204
+ }
205
+
206
+ window.addEventListener('keydown', handleKeyDown)
207
+ return () => window.removeEventListener('keydown', handleKeyDown)
208
+ }, [keyboardNavigation, isFocusWithinContainer, increaseCursor, decreaseCursor])
209
+
210
+ // Handle Escape key via event listener to avoid state-based re-renders
211
+ useEffect(() => {
212
+ if (keyboardNavigation === false) {
213
+ return
214
+ }
215
+ const handleEscapeKey = (event: KeyboardEvent) => {
216
+ if (event.key !== 'Escape' || !isFocusWithinContainer()) {
217
+ return
218
+ }
219
+
220
+ if (editingCellRef.current) {
221
+ setEditingCell(null)
222
+ } else if (selectedRowsRef.current && isSelectionChangeAllowed(null, null)) {
223
+ setSelectedRows({ ids: [], id: null })
224
+ }
225
+ }
226
+
227
+ window.addEventListener('keydown', handleEscapeKey)
228
+ return () => window.removeEventListener('keydown', handleEscapeKey)
229
+ }, [
230
+ keyboardNavigation,
231
+ isFocusWithinContainer,
232
+ setEditingCell,
233
+ setSelectedRows,
234
+ isSelectionChangeAllowed,
235
+ ])
236
+
237
+ const setCursorOnRowSelectionChange = useCallback(() => {
238
+ if (selectedRows && selectedRows.id) {
239
+ // Find all rows with matching ID
240
+ const matchingRows = indexes.filter((item) => selectedRows.id === item[1])
241
+
242
+ if (matchingRows.length > 0) {
243
+ // If we have a current cursor, prefer the row at that position if it matches
244
+ if (!isNull(cursor)) {
245
+ const currentRow = indexes.find((item) => item[0] === cursor)
246
+ if (currentRow && currentRow[1] === selectedRows.id) {
247
+ // Current cursor position matches the selected ID, keep it
248
+ return
249
+ }
250
+
251
+ // Find the matching row closest to the current cursor position
252
+ const closest = matchingRows.reduce((prev, curr) => {
253
+ const prevDiff = Math.abs(prev[0] - cursor)
254
+ const currDiff = Math.abs(curr[0] - cursor)
255
+ return currDiff < prevDiff ? curr : prev
256
+ })
257
+
258
+ if (cursor !== closest[0]) {
259
+ setCursor(closest[0])
260
+ }
261
+ } else {
262
+ // No cursor, use the first match
263
+ if (cursor !== matchingRows[0][0]) {
264
+ setCursor(matchingRows[0][0])
265
+ }
266
+ }
267
+ }
268
+ } else {
269
+ setCursor(null)
270
+ }
271
+ }, [cursor, selectedRows, indexes])
272
+
273
+ useEffect(setCursorOnRowSelectionChange, [selectedRows, indexes])
274
+ }
@@ -0,0 +1,90 @@
1
+ import { isNumber, mapValues, reduce } from 'lodash-es'
2
+ import { Children, ReactElement, ReactNode, isValidElement } from 'react'
3
+ import { TableNode } from '@table-library/react-table-library/types/table'
4
+ import { isGroupHeader } from './is-group-header'
5
+ import { useTable } from './table-context'
6
+
7
+ interface ColumnProps {
8
+ sortKey?: string
9
+ sortFn?: (array: Array<TableNode>) => Array<TableNode>
10
+ }
11
+
12
+ interface HeaderProps {
13
+ children?: ReactNode
14
+ }
15
+
16
+ export const useSortFunctions = (header: ReactElement<HeaderProps>, grouped?: boolean) => {
17
+ const sortFunctions: Record<number, (array: Array<TableNode>) => Array<TableNode>> = {}
18
+ const { select } = useTable()
19
+
20
+ const defaultSort = (sortKey: string) => {
21
+ return (array: Array<TableNode>) => {
22
+ // If an array of numbers then sort numerically.
23
+ if (isNumber(array[0][sortKey])) {
24
+ return array.sort((a, b) => a[sortKey] - b[sortKey])
25
+ }
26
+
27
+ // Otherwise, sort alphabetically.
28
+ return array.sort((a, b) => {
29
+ if (a[sortKey] < b[sortKey]) {
30
+ return -1
31
+ }
32
+
33
+ if (a[sortKey] > b[sortKey]) {
34
+ return 1
35
+ }
36
+
37
+ return 0
38
+ })
39
+ }
40
+ }
41
+
42
+ Children.toArray(header.props.children).forEach((child, index) => {
43
+ if (!isValidElement<ColumnProps>(child)) {
44
+ return
45
+ }
46
+
47
+ const sortKey = child.props.sortKey
48
+ sortFunctions[select ? index + 1 : index] = !child.props.sortFn
49
+ ? defaultSort(sortKey!)
50
+ : child.props.sortFn
51
+ })
52
+
53
+ const groupSort = (sortFunction: (tableList: Array<TableNode>) => Array<TableNode>) => {
54
+ return (tableList: Array<TableNode>) => {
55
+ const splitLists = reduce(
56
+ tableList,
57
+ (acc, item) => {
58
+ if (isGroupHeader(item)) {
59
+ acc.push([])
60
+ } else {
61
+ acc[acc.length - 1].push(item)
62
+ }
63
+ return acc
64
+ },
65
+ [] as Array<Array<TableNode>>,
66
+ )
67
+
68
+ splitLists.map((list) => (list.length ? sortFunction(list) : list))
69
+
70
+ const combineLists: Array<TableNode | Array<TableNode>> = []
71
+ const headers = tableList.filter((item) => isGroupHeader(item))
72
+ for (let i = 0; i < headers.length; i++) {
73
+ combineLists.push(headers[i])
74
+ combineLists.push(splitLists[i])
75
+ }
76
+
77
+ // The library tolerates the nested sub-lists this produces; keep the
78
+ // vendored runtime shape and only assert the declared type.
79
+ return combineLists as Array<TableNode>
80
+ }
81
+ }
82
+
83
+ // If table items are grouped, then split them by headers before sorting
84
+ // and then stitch back together after sorting.
85
+ if (grouped) {
86
+ return mapValues(sortFunctions, groupSort)
87
+ }
88
+
89
+ return sortFunctions
90
+ }
@@ -0,0 +1,20 @@
1
+ import { ReactElement } from 'react'
2
+ import { List, TabsListProps } from './list'
3
+ import { Tab, TabProps } from './tab'
4
+ import { Tabs as TabsRoot, TabsProps } from './tabs'
5
+ import { TabsSize, useTabs } from './tabs-context'
6
+
7
+ type TabsCompoundType = {
8
+ (props: TabsProps): ReactElement
9
+ List: typeof List
10
+ Tab: typeof Tab
11
+ useTabs: typeof useTabs
12
+ }
13
+
14
+ export const Tabs: TabsCompoundType = Object.assign(TabsRoot, {
15
+ List,
16
+ Tab,
17
+ useTabs,
18
+ })
19
+
20
+ export type { TabsProps, TabsListProps, TabProps, TabsSize }
@@ -0,0 +1,29 @@
1
+ import React, { ReactNode } from 'react'
2
+ import { cn } from '../helpers'
3
+ import { useTabs } from './tabs-context'
4
+
5
+ export type TabsListProps = {
6
+ className?: string
7
+ children: ReactNode
8
+ }
9
+
10
+ export const List = ({ className, children }: TabsListProps) => {
11
+ const { size } = useTabs()
12
+
13
+ return (
14
+ <div
15
+ role="tablist"
16
+ className={cn(
17
+ 'flex flex-nowrap',
18
+ {
19
+ 'items-start gap-6': size === 'lg',
20
+ 'h-10 items-center gap-0.5 shrink-0 px-1.5 overflow-x-scroll hide-scrollbar':
21
+ size === 'md',
22
+ },
23
+ className,
24
+ )}
25
+ >
26
+ {children}
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,141 @@
1
+ import React, { HTMLAttributes, ReactNode, Ref } from 'react'
2
+ import { cn } from '../helpers'
3
+ import { Link } from '../link'
4
+ import { useTabs } from './tabs-context'
5
+
6
+ export type TabProps = HTMLAttributes<HTMLButtonElement | HTMLAnchorElement> & {
7
+ value: string
8
+ className?: string
9
+ children: ReactNode
10
+ asLink?: boolean
11
+ href?: string
12
+ type?: 'button' | 'submit' | 'reset'
13
+ ref?: Ref<HTMLButtonElement | HTMLAnchorElement>
14
+ }
15
+
16
+ export const Tab = ({
17
+ value,
18
+ className,
19
+ children,
20
+ onClick,
21
+ asLink = false,
22
+ href,
23
+ type = 'button',
24
+ ref,
25
+ ...restProps
26
+ }: TabProps) => {
27
+ const { value: selectedValue, onValueChange, size } = useTabs()
28
+ const isActive = value === selectedValue
29
+
30
+ const scrollToCenter = (el: HTMLElement) => {
31
+ el.scrollIntoView?.({
32
+ behavior: 'smooth',
33
+ inline: 'center',
34
+ block: 'nearest',
35
+ })
36
+ }
37
+
38
+ const handleClick = (e: React.MouseEvent<HTMLElement>) => {
39
+ onValueChange(value)
40
+ scrollToCenter(e.currentTarget)
41
+ onClick?.(e as React.MouseEvent<HTMLButtonElement & HTMLAnchorElement>)
42
+ }
43
+
44
+ const handleFocus = (e: React.FocusEvent<HTMLElement>) => {
45
+ if (e.currentTarget.matches(':focus-visible')) {
46
+ scrollToCenter(e.currentTarget)
47
+ }
48
+ }
49
+
50
+ const sharedProps = {
51
+ role: 'tab' as const,
52
+ tabIndex: isActive ? -1 : 0,
53
+ 'aria-selected': isActive,
54
+ onClick: handleClick,
55
+ onFocus: handleFocus,
56
+ ...restProps,
57
+ }
58
+
59
+ if (size === 'md') {
60
+ const tabClassName = cn(
61
+ 'whitespace-nowrap text-xs text-gray-300 dark:text-zinc-400 font-medium select-none',
62
+ 'rounded p-1 px-2 hover:text-gray-700 hover:bg-gray-50 dark:hover:text-zinc-100 dark:hover:bg-zinc-800',
63
+ 'outline-none focus-visible:ring-2 ring-info/75',
64
+ {
65
+ 'text-gray-700 bg-gray-50 dark:text-zinc-100 dark:bg-zinc-800': isActive,
66
+ },
67
+ className,
68
+ )
69
+
70
+ if (asLink) {
71
+ return (
72
+ <Link
73
+ ref={ref as Ref<HTMLAnchorElement>}
74
+ href={href}
75
+ className={cn('text-inherit no-underline hover:no-underline', tabClassName)}
76
+ {...sharedProps}
77
+ >
78
+ {children}
79
+ </Link>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <button
85
+ ref={ref as Ref<HTMLButtonElement>}
86
+ type={type}
87
+ className={tabClassName}
88
+ {...sharedProps}
89
+ >
90
+ {children}
91
+ </button>
92
+ )
93
+ }
94
+
95
+ const wrapperClassName = cn(
96
+ '-mb-px rounded py-2 px-0 text-sm font-bold text-gray-300 dark:text-zinc-400 text-center',
97
+ 'whitespace-nowrap',
98
+ {
99
+ 'rounded-b-none border-b-4 border-info text-gray-500 dark:text-zinc-100': isActive,
100
+ },
101
+ )
102
+
103
+ const innerClassName = cn(
104
+ 'rounded-xl px-3 py-1 outline-none',
105
+ 'focus-visible:ring-2 focus-visible:ring-info/75',
106
+ 'transition-all duration-200 ease-in-out',
107
+ {
108
+ 'hover:text-gray-500 hover:bg-gray-100/50 dark:hover:text-zinc-100 dark:hover:bg-zinc-800/50':
109
+ !isActive,
110
+ },
111
+ className,
112
+ )
113
+
114
+ if (asLink) {
115
+ return (
116
+ <div data-wrapper className={wrapperClassName}>
117
+ <Link
118
+ ref={ref as Ref<HTMLAnchorElement>}
119
+ href={href}
120
+ className={cn('text-inherit no-underline hover:no-underline', innerClassName)}
121
+ {...sharedProps}
122
+ >
123
+ {children}
124
+ </Link>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ return (
130
+ <div data-wrapper className={wrapperClassName}>
131
+ <button
132
+ ref={ref as Ref<HTMLButtonElement>}
133
+ type={type}
134
+ className={innerClassName}
135
+ {...sharedProps}
136
+ >
137
+ {children}
138
+ </button>
139
+ </div>
140
+ )
141
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import React, { createContext, ReactNode, useContext } from 'react'
4
+
5
+ export type TabsSize = 'md' | 'lg'
6
+
7
+ export interface TabsContextValue {
8
+ value: string
9
+ onValueChange: (value: string) => void
10
+ size: TabsSize
11
+ }
12
+
13
+ export const TabsContext = createContext<TabsContextValue | null>(null)
14
+
15
+ export const useTabs = () => {
16
+ const context = useContext(TabsContext)
17
+ if (!context) {
18
+ throw new Error('Tabs.Tab must be used within a Tabs component')
19
+ }
20
+ return context
21
+ }
22
+
23
+ interface TabsProviderProps {
24
+ children: ReactNode
25
+ value: string
26
+ onValueChange: (value: string) => void
27
+ size: TabsSize
28
+ }
29
+
30
+ export const TabsProvider = ({ children, value, onValueChange, size }: TabsProviderProps) => {
31
+ const contextValue: TabsContextValue = {
32
+ value,
33
+ onValueChange,
34
+ size,
35
+ }
36
+
37
+ return <TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider>
38
+ }
@@ -0,0 +1,27 @@
1
+ import React, { ReactNode } from 'react'
2
+ import { cn } from '../helpers'
3
+ import { TabsProvider, TabsSize } from './tabs-context'
4
+
5
+ export type TabsProps = {
6
+ value: string
7
+ onValueChange: (value: string) => void
8
+ size?: TabsSize
9
+ className?: string
10
+ children: ReactNode
11
+ }
12
+
13
+ export const Tabs = ({ value, onValueChange, size = 'lg', className, children }: TabsProps) => (
14
+ <TabsProvider value={value} onValueChange={onValueChange} size={size}>
15
+ <div
16
+ className={cn(
17
+ 'border-b border-gray-100 dark:border-zinc-800',
18
+ {
19
+ 'overflow-x-auto overflow-y-hidden hide-scrollbar': size === 'lg',
20
+ },
21
+ className,
22
+ )}
23
+ >
24
+ {children}
25
+ </div>
26
+ </TabsProvider>
27
+ )
@@ -0,0 +1 @@
1
+ export * from './textarea'