@shipfox/react-ui 0.12.0 → 0.13.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 (63) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/.turbo/turbo-check.log +2 -2
  3. package/.turbo/turbo-type.log +1 -1
  4. package/CHANGELOG.md +6 -0
  5. package/dist/components/badge/index.d.ts +4 -4
  6. package/dist/components/badge/index.d.ts.map +1 -1
  7. package/dist/components/badge/index.js +4 -4
  8. package/dist/components/badge/index.js.map +1 -1
  9. package/dist/components/dropdown-menu/index.d.ts +1 -2
  10. package/dist/components/dropdown-menu/index.d.ts.map +1 -1
  11. package/dist/components/dropdown-menu/index.js +1 -1
  12. package/dist/components/dropdown-menu/index.js.map +1 -1
  13. package/dist/components/index.d.ts +2 -0
  14. package/dist/components/index.d.ts.map +1 -1
  15. package/dist/components/index.js +2 -0
  16. package/dist/components/index.js.map +1 -1
  17. package/dist/components/modal/index.d.ts +1 -2
  18. package/dist/components/modal/index.d.ts.map +1 -1
  19. package/dist/components/modal/index.js +1 -1
  20. package/dist/components/modal/index.js.map +1 -1
  21. package/dist/components/modal/modal.d.ts +2 -1
  22. package/dist/components/modal/modal.d.ts.map +1 -1
  23. package/dist/components/modal/modal.js +5 -3
  24. package/dist/components/modal/modal.js.map +1 -1
  25. package/dist/components/modal/modal.stories.js +2 -0
  26. package/dist/components/modal/modal.stories.js.map +1 -1
  27. package/dist/components/tabs/index.d.ts +2 -0
  28. package/dist/components/tabs/index.d.ts.map +1 -0
  29. package/dist/components/tabs/index.js +3 -0
  30. package/dist/components/tabs/index.js.map +1 -0
  31. package/dist/components/tabs/tabs.d.ts +50 -0
  32. package/dist/components/tabs/tabs.d.ts.map +1 -0
  33. package/dist/components/tabs/tabs.js +243 -0
  34. package/dist/components/tabs/tabs.js.map +1 -0
  35. package/dist/components/tabs/tabs.stories.js +179 -0
  36. package/dist/components/tabs/tabs.stories.js.map +1 -0
  37. package/dist/components/toast/index.d.ts +2 -2
  38. package/dist/components/toast/index.d.ts.map +1 -1
  39. package/dist/components/toast/index.js +2 -2
  40. package/dist/components/toast/index.js.map +1 -1
  41. package/dist/styles.css +1 -1
  42. package/dist/utils/debounce.d.ts +2 -0
  43. package/dist/utils/debounce.d.ts.map +1 -0
  44. package/dist/utils/debounce.js +13 -0
  45. package/dist/utils/debounce.js.map +1 -0
  46. package/dist/utils/index.d.ts +1 -0
  47. package/dist/utils/index.d.ts.map +1 -1
  48. package/dist/utils/index.js +1 -0
  49. package/dist/utils/index.js.map +1 -1
  50. package/index.css +3 -0
  51. package/package.json +1 -1
  52. package/src/components/badge/index.ts +4 -4
  53. package/src/components/dropdown-menu/index.ts +1 -29
  54. package/src/components/index.ts +2 -0
  55. package/src/components/modal/index.ts +1 -23
  56. package/src/components/modal/modal.stories.tsx +2 -2
  57. package/src/components/modal/modal.tsx +4 -2
  58. package/src/components/tabs/index.ts +1 -0
  59. package/src/components/tabs/tabs.stories.tsx +100 -0
  60. package/src/components/tabs/tabs.tsx +380 -0
  61. package/src/components/toast/index.ts +2 -2
  62. package/src/utils/debounce.ts +15 -0
  63. package/src/utils/index.ts +1 -0
@@ -140,7 +140,7 @@ export const ImportForm: Story = {
140
140
  <ModalTrigger asChild>
141
141
  <Button>Import past jobs from GitHub</Button>
142
142
  </ModalTrigger>
143
- <ModalContent aria-describedby={undefined}>
143
+ <ModalContent aria-describedby={undefined} overlayClassName="bg-background-modal-overlay">
144
144
  <ModalTitle className="sr-only">Import past jobs from GitHub</ModalTitle>
145
145
  <ModalHeader title="Import past jobs from GitHub" />
146
146
  <ModalBody className="gap-20">
@@ -221,7 +221,7 @@ export const GithubActions: Story = {
221
221
  <ModalTrigger asChild>
222
222
  <Button>Run GitHub Actions on Shipfox</Button>
223
223
  </ModalTrigger>
224
- <ModalContent aria-describedby={undefined}>
224
+ <ModalContent aria-describedby={undefined} overlayClassName="bg-background-modal-overlay">
225
225
  <ModalTitle className="sr-only">Run GitHub Actions on Shipfox</ModalTitle>
226
226
  <ModalHeader title="Run GitHub Actions on Shipfox" />
227
227
  <ModalBody className="gap-32">
@@ -125,6 +125,7 @@ function ModalOverlay({
125
125
  type ModalContentProps = ComponentProps<typeof DialogPrimitive.Content> & {
126
126
  animated?: boolean;
127
127
  transition?: Transition;
128
+ overlayClassName?: string;
128
129
  };
129
130
 
130
131
  function ModalContent({
@@ -132,6 +133,7 @@ function ModalContent({
132
133
  children,
133
134
  animated = true,
134
135
  transition = modalDefaultTransition,
136
+ overlayClassName,
135
137
  ...props
136
138
  }: ModalContentProps) {
137
139
  const {isDesktop} = useModalContext();
@@ -139,7 +141,7 @@ function ModalContent({
139
141
  if (!isDesktop) {
140
142
  return (
141
143
  <ModalPortal>
142
- <ModalOverlay animated={animated} transition={transition} />
144
+ <ModalOverlay animated={animated} transition={transition} className={overlayClassName} />
143
145
  <VaulDrawer.Content
144
146
  className={cn(
145
147
  'fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background-neutral-base rounded-t-16 max-h-[85vh] shadow-tooltip',
@@ -163,7 +165,7 @@ function ModalContent({
163
165
 
164
166
  return (
165
167
  <ModalPortal>
166
- <ModalOverlay animated={animated} transition={transition} />
168
+ <ModalOverlay animated={animated} transition={transition} className={overlayClassName} />
167
169
  <DialogPrimitive.Content className={baseClasses} {...props}>
168
170
  <div className="relative size-full">
169
171
  <div className="pointer-events-none absolute inset-0 shadow-separator-inset rounded-16" />
@@ -0,0 +1 @@
1
+ export * from './tabs';
@@ -0,0 +1,100 @@
1
+ import type {Meta, StoryObj} from '@storybook/react';
2
+ import {useState} from 'react';
3
+ import {Tabs, TabsContent, TabsContents, TabsList, TabsTrigger} from '.';
4
+
5
+ const meta = {
6
+ title: 'Components/Tabs',
7
+ component: Tabs,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ } satisfies Meta<typeof Tabs>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ export const Default: Story = {
18
+ args: {defaultValue: 'analytics'} as never,
19
+ render: () => (
20
+ <div className="bg-background-neutral-background p-24 w-[80vw]">
21
+ <Tabs defaultValue="analytics">
22
+ <TabsList className="gap-12 border-b border-neutral-strong">
23
+ <TabsTrigger value="analytics">Analytics</TabsTrigger>
24
+ <TabsTrigger value="jobs">Jobs</TabsTrigger>
25
+ </TabsList>
26
+ </Tabs>
27
+ </div>
28
+ ),
29
+ };
30
+
31
+ export const Controlled: Story = {
32
+ args: {value: 'analytics', onValueChange: () => undefined} as never,
33
+ render: () => {
34
+ const [value, setValue] = useState('analytics');
35
+ return (
36
+ <div className="bg-background-neutral-background p-24 w-[80vw]">
37
+ <Tabs value={value} onValueChange={setValue}>
38
+ <TabsList className="gap-12 border-b border-neutral-strong">
39
+ <TabsTrigger value="analytics">Analytics</TabsTrigger>
40
+ <TabsTrigger value="jobs">Jobs</TabsTrigger>
41
+ </TabsList>
42
+ <TabsContents>
43
+ <TabsContent value="analytics">
44
+ <div className="py-16">
45
+ <p className="text-foreground-neutral-base">
46
+ Analytics content - Current value: {value}
47
+ </p>
48
+ </div>
49
+ </TabsContent>
50
+ <TabsContent value="jobs">
51
+ <div className="py-16">
52
+ <p className="text-foreground-neutral-base">
53
+ Jobs content - Current value: {value}
54
+ </p>
55
+ </div>
56
+ </TabsContent>
57
+ </TabsContents>
58
+ </Tabs>
59
+ </div>
60
+ );
61
+ },
62
+ };
63
+
64
+ export const MultipleTabs: Story = {
65
+ args: {defaultValue: 'tab1'} as never,
66
+ render: () => (
67
+ <div className="bg-background-neutral-background p-24 w-[80vw]">
68
+ <Tabs defaultValue="tab1">
69
+ <TabsList className="gap-12 border-b border-neutral-strong">
70
+ <TabsTrigger value="tab1">Tab 1</TabsTrigger>
71
+ <TabsTrigger value="tab2">Tab 2</TabsTrigger>
72
+ <TabsTrigger value="tab3">Tab 3</TabsTrigger>
73
+ <TabsTrigger value="tab4">Tab 4</TabsTrigger>
74
+ </TabsList>
75
+ <TabsContents>
76
+ <TabsContent value="tab1">
77
+ <div className="py-16">
78
+ <p className="text-foreground-neutral-base">Content for Tab 1</p>
79
+ </div>
80
+ </TabsContent>
81
+ <TabsContent value="tab2">
82
+ <div className="py-16">
83
+ <p className="text-foreground-neutral-base">Content for Tab 2</p>
84
+ </div>
85
+ </TabsContent>
86
+ <TabsContent value="tab3">
87
+ <div className="py-16">
88
+ <p className="text-foreground-neutral-base">Content for Tab 3</p>
89
+ </div>
90
+ </TabsContent>
91
+ <TabsContent value="tab4">
92
+ <div className="py-16">
93
+ <p className="text-foreground-neutral-base">Content for Tab 4</p>
94
+ </div>
95
+ </TabsContent>
96
+ </TabsContents>
97
+ </Tabs>
98
+ </div>
99
+ ),
100
+ };
@@ -0,0 +1,380 @@
1
+ import {type HTMLMotionProps, motion, type Transition} from 'framer-motion';
2
+ import {
3
+ Children,
4
+ type ComponentProps,
5
+ createContext,
6
+ forwardRef,
7
+ isValidElement,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useImperativeHandle,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
18
+ import {cn} from 'utils/cn';
19
+ import {debounce} from 'utils/debounce';
20
+
21
+ type TabsContextType<T extends string = string> = {
22
+ activeValue: T;
23
+ handleValueChange: (value: T) => void;
24
+ registerTrigger: (value: string, node: HTMLElement | null) => void;
25
+ getTriggerElement: (value: string) => HTMLElement | undefined;
26
+ getAllTriggerValues: () => string[];
27
+ };
28
+
29
+ const TabsContext = createContext<TabsContextType<string> | undefined>(undefined);
30
+
31
+ function useTabs<T extends string = string>(): TabsContextType<T> {
32
+ const context = useContext(TabsContext);
33
+ if (!context) {
34
+ throw new Error('useTabs must be used within a Tabs component');
35
+ }
36
+ return context as unknown as TabsContextType<T>;
37
+ }
38
+
39
+ type BaseTabsProps = ComponentProps<'div'> & {
40
+ children: ReactNode;
41
+ };
42
+
43
+ type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
44
+ defaultValue?: T;
45
+ value?: never;
46
+ onValueChange?: never;
47
+ };
48
+
49
+ type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
50
+ value: T;
51
+ onValueChange?: (value: T) => void;
52
+ defaultValue?: never;
53
+ };
54
+
55
+ type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>;
56
+
57
+ function Tabs<T extends string = string>({
58
+ defaultValue,
59
+ value,
60
+ onValueChange,
61
+ children,
62
+ className,
63
+ ...props
64
+ }: TabsProps<T>) {
65
+ const [activeValue, setActiveValue] = useState<T | undefined>(defaultValue ?? undefined);
66
+ const triggersRef = useRef(new Map<string, HTMLElement>());
67
+ const initialSet = useRef(false);
68
+ const isControlled = value !== undefined;
69
+
70
+ useEffect(() => {
71
+ if (
72
+ !isControlled &&
73
+ activeValue === undefined &&
74
+ triggersRef.current.size > 0 &&
75
+ !initialSet.current
76
+ ) {
77
+ const firstTab = Array.from(triggersRef.current.keys())[0];
78
+ setActiveValue(firstTab as T);
79
+ initialSet.current = true;
80
+ }
81
+ }, [activeValue, isControlled]);
82
+
83
+ const registerTrigger = useCallback(
84
+ (value: string, node: HTMLElement | null) => {
85
+ if (node) {
86
+ triggersRef.current.set(value, node);
87
+ if (!isControlled && activeValue === undefined && !initialSet.current) {
88
+ setActiveValue(value as T);
89
+ initialSet.current = true;
90
+ }
91
+ } else {
92
+ triggersRef.current.delete(value);
93
+ }
94
+ },
95
+ [isControlled, activeValue],
96
+ );
97
+
98
+ const handleValueChange = useCallback(
99
+ (val: T) => {
100
+ if (!isControlled) setActiveValue(val);
101
+ else onValueChange?.(val);
102
+ },
103
+ [isControlled, onValueChange],
104
+ );
105
+
106
+ const getTriggerElement = useCallback((val: string) => {
107
+ return triggersRef.current.get(val);
108
+ }, []);
109
+
110
+ const getAllTriggerValues = useCallback(() => {
111
+ return Array.from(triggersRef.current.keys());
112
+ }, []);
113
+
114
+ const resolvedActiveValue: T = useMemo(() => {
115
+ if (value !== undefined) return value;
116
+ if (activeValue !== undefined) return activeValue;
117
+ const firstKey = Array.from(triggersRef.current.keys())[0];
118
+ return (firstKey ?? '') as T;
119
+ }, [value, activeValue]);
120
+
121
+ return (
122
+ <TabsContext.Provider
123
+ value={{
124
+ activeValue: resolvedActiveValue as string,
125
+ handleValueChange: handleValueChange as (value: string) => void,
126
+ registerTrigger,
127
+ getTriggerElement,
128
+ getAllTriggerValues,
129
+ }}
130
+ >
131
+ <div
132
+ data-slot="tabs"
133
+ className={cn('flex flex-col gap-2', className)}
134
+ {...(props as ComponentProps<'div'>)}
135
+ >
136
+ {children}
137
+ </div>
138
+ </TabsContext.Provider>
139
+ );
140
+ }
141
+
142
+ type TabsListProps = ComponentProps<'div'> & {
143
+ children: ReactNode;
144
+ activeClassName?: string;
145
+ transition?: Transition;
146
+ };
147
+
148
+ function TabsList({
149
+ children,
150
+ className,
151
+ activeClassName,
152
+ transition = {
153
+ type: 'spring',
154
+ stiffness: 400,
155
+ damping: 30,
156
+ },
157
+ ...props
158
+ }: TabsListProps) {
159
+ const {activeValue, getTriggerElement} = useTabs();
160
+ const [indicatorStyle, setIndicatorStyle] = useState<{
161
+ left: number;
162
+ width: number;
163
+ } | null>(null);
164
+ const listRef = useRef<HTMLDivElement>(null);
165
+
166
+ const updateIndicator = useCallback(() => {
167
+ const activeTrigger = getTriggerElement(activeValue);
168
+
169
+ if (activeTrigger && listRef.current) {
170
+ const listRect = listRef.current.getBoundingClientRect();
171
+ const triggerRect = activeTrigger.getBoundingClientRect();
172
+ setIndicatorStyle({
173
+ left: triggerRect.left - listRect.left,
174
+ width: triggerRect.width,
175
+ });
176
+ }
177
+ }, [activeValue, getTriggerElement]);
178
+
179
+ useEffect(() => {
180
+ const debouncedUpdate = debounce(updateIndicator, 100);
181
+ window.addEventListener('resize', debouncedUpdate);
182
+ requestAnimationFrame(updateIndicator);
183
+
184
+ return () => {
185
+ window.removeEventListener('resize', debouncedUpdate);
186
+ };
187
+ }, [updateIndicator]);
188
+
189
+ return (
190
+ <div
191
+ ref={listRef}
192
+ role="tablist"
193
+ data-slot="tabs-list"
194
+ className={cn('relative inline-flex items-center gap-8', className)}
195
+ {...(props as ComponentProps<'div'>)}
196
+ >
197
+ {children}
198
+ {indicatorStyle && (
199
+ <motion.div
200
+ className={cn(
201
+ 'absolute bottom-0 h-2 bg-foreground-highlight-interactive',
202
+ activeClassName,
203
+ )}
204
+ initial={false}
205
+ animate={{
206
+ left: indicatorStyle.left,
207
+ width: indicatorStyle.width,
208
+ }}
209
+ transition={transition}
210
+ />
211
+ )}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ type TabsTriggerProps = Omit<HTMLMotionProps<'button'>, 'ref'> & {
217
+ value: string;
218
+ children: ReactNode;
219
+ };
220
+
221
+ const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
222
+ ({value, children, className, onKeyDown, ...props}, ref) => {
223
+ const {
224
+ activeValue,
225
+ handleValueChange,
226
+ registerTrigger,
227
+ getAllTriggerValues,
228
+ getTriggerElement,
229
+ } = useTabs();
230
+
231
+ const localRef = useRef<HTMLButtonElement | null>(null);
232
+ useImperativeHandle(ref, () => localRef.current as HTMLButtonElement);
233
+
234
+ useEffect(() => {
235
+ registerTrigger(value, localRef.current);
236
+ return () => registerTrigger(value, null);
237
+ }, [value, registerTrigger]);
238
+
239
+ const isActive = activeValue === value;
240
+
241
+ const handleKeyDown = useCallback(
242
+ (event: React.KeyboardEvent<HTMLButtonElement>) => {
243
+ onKeyDown?.(event);
244
+
245
+ const allValues = getAllTriggerValues();
246
+ const currentIndex = allValues.indexOf(value);
247
+
248
+ if (currentIndex === -1) return;
249
+
250
+ let targetIndex = currentIndex;
251
+ let shouldPreventDefault = true;
252
+
253
+ switch (event.key) {
254
+ case 'ArrowLeft': {
255
+ targetIndex = currentIndex > 0 ? currentIndex - 1 : allValues.length - 1;
256
+ break;
257
+ }
258
+ case 'ArrowRight': {
259
+ targetIndex = currentIndex < allValues.length - 1 ? currentIndex + 1 : 0;
260
+ break;
261
+ }
262
+ case 'Home': {
263
+ targetIndex = 0;
264
+ break;
265
+ }
266
+ case 'End': {
267
+ targetIndex = allValues.length - 1;
268
+ break;
269
+ }
270
+ default: {
271
+ shouldPreventDefault = false;
272
+ return;
273
+ }
274
+ }
275
+
276
+ if (shouldPreventDefault) {
277
+ event.preventDefault();
278
+ const targetValue = allValues[targetIndex];
279
+ if (targetValue) {
280
+ handleValueChange(targetValue);
281
+ const targetElement = getTriggerElement(targetValue);
282
+ targetElement?.focus();
283
+ }
284
+ }
285
+ },
286
+ [value, getAllTriggerValues, getTriggerElement, handleValueChange, onKeyDown],
287
+ );
288
+
289
+ return (
290
+ <motion.button
291
+ ref={localRef}
292
+ data-slot="tabs-trigger"
293
+ role="tab"
294
+ tabIndex={isActive ? 0 : -1}
295
+ whileTap={{scale: 0.95}}
296
+ onClick={() => handleValueChange(value)}
297
+ onKeyDown={handleKeyDown}
298
+ data-state={isActive ? 'active' : 'inactive'}
299
+ aria-selected={isActive}
300
+ aria-controls={`tabpanel-${value}`}
301
+ id={`tab-${value}`}
302
+ className={cn(
303
+ 'relative inline-flex cursor-pointer items-center justify-center whitespace-nowrap px-0 py-10 text-sm font-medium transition-colors outline-none focus-visible:shadow-border-interactive-with-active focus-visible:rounded-2 disabled:pointer-events-none disabled:opacity-50',
304
+ isActive ? 'text-foreground-neutral-base' : 'text-foreground-neutral-muted',
305
+ className,
306
+ )}
307
+ {...props}
308
+ >
309
+ {children}
310
+ </motion.button>
311
+ );
312
+ },
313
+ );
314
+
315
+ TabsTrigger.displayName = 'TabsTrigger';
316
+
317
+ type TabsContentsProps = ComponentProps<'div'> & {
318
+ children: ReactNode;
319
+ };
320
+
321
+ function TabsContents({children, className, ...props}: TabsContentsProps) {
322
+ const {activeValue} = useTabs();
323
+ const childrenArray = Children.toArray(children);
324
+
325
+ const activeChild = childrenArray.find(
326
+ (child): child is ReactElement<{value: string}> =>
327
+ isValidElement(child) &&
328
+ typeof child.props === 'object' &&
329
+ child.props !== null &&
330
+ 'value' in child.props &&
331
+ child.props.value === activeValue,
332
+ );
333
+
334
+ return (
335
+ <div data-slot="tabs-contents" className={cn(className)} {...(props as ComponentProps<'div'>)}>
336
+ {activeChild}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ type TabsContentProps = ComponentProps<'div'> & {
342
+ value: string;
343
+ children: ReactNode;
344
+ };
345
+
346
+ function TabsContent({children, value, className, ...props}: TabsContentProps) {
347
+ const {activeValue} = useTabs();
348
+ const isActive = activeValue === value;
349
+
350
+ if (!isActive) {
351
+ return null;
352
+ }
353
+
354
+ return (
355
+ <div
356
+ role="tabpanel"
357
+ data-slot="tabs-content"
358
+ aria-labelledby={`tab-${value}`}
359
+ className={cn(className)}
360
+ {...props}
361
+ >
362
+ {children}
363
+ </div>
364
+ );
365
+ }
366
+
367
+ export {
368
+ Tabs,
369
+ TabsList,
370
+ TabsTrigger,
371
+ TabsContents,
372
+ TabsContent,
373
+ useTabs,
374
+ type TabsContextType,
375
+ type TabsProps,
376
+ type TabsListProps,
377
+ type TabsTriggerProps,
378
+ type TabsContentsProps,
379
+ type TabsContentProps,
380
+ };
@@ -1,2 +1,2 @@
1
- export {Toaster, type ToasterProps, toast} from './toast';
2
- export {ToastCustom, type ToastCustomProps} from './toast-custom';
1
+ export * from './toast';
2
+ export * from './toast-custom';
@@ -0,0 +1,15 @@
1
+ export function debounce<T extends (...args: never[]) => void>(
2
+ fn: T,
3
+ delay: number,
4
+ ): (...args: Parameters<T>) => void {
5
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
6
+
7
+ return (...args: Parameters<T>) => {
8
+ if (timeoutId) {
9
+ clearTimeout(timeoutId);
10
+ }
11
+ timeoutId = setTimeout(() => {
12
+ fn(...args);
13
+ }, delay);
14
+ };
15
+ }
@@ -2,4 +2,5 @@ export * from './avatar';
2
2
  export * from './clipboard';
3
3
  export * from './cn';
4
4
  export * from './date';
5
+ export * from './debounce';
5
6
  export * from './format';