@dxos/react-ui-tabs 0.8.3 → 0.8.4-main.1068cf700f

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/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-tabs",
3
- "version": "0.8.3",
3
+ "version": "0.8.4-main.1068cf700f",
4
4
  "description": "Components for facilitating a Tabs pattern.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
7
11
  "license": "MIT",
8
12
  "author": "DXOS.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
11
15
  "exports": {
12
16
  ".": {
17
+ "source": "./src/index.ts",
13
18
  "types": "./dist/types/src/index.d.ts",
14
19
  "browser": "./dist/lib/browser/index.mjs",
15
20
  "node": "./dist/lib/node-esm/index.mjs"
@@ -24,33 +29,32 @@
24
29
  "src"
25
30
  ],
26
31
  "dependencies": {
27
- "@fluentui/react-tabster": "^9.24.2",
28
- "@preact-signals/safe-react": "^0.9.0",
32
+ "@fluentui/react-tabster": "9.26.11",
29
33
  "@radix-ui/primitive": "1.1.1",
30
34
  "@radix-ui/react-context": "1.1.1",
31
35
  "@radix-ui/react-primitive": "2.0.2",
32
36
  "@radix-ui/react-slot": "1.1.2",
33
37
  "@radix-ui/react-tabs": "1.1.3",
34
38
  "@radix-ui/react-use-controllable-state": "1.1.0",
35
- "@dxos/react-ui-attention": "0.8.3",
36
- "@dxos/util": "0.8.3"
39
+ "@dxos/react-ui-attention": "0.8.4-main.1068cf700f",
40
+ "@dxos/util": "0.8.4-main.1068cf700f"
37
41
  },
38
42
  "devDependencies": {
39
- "@types/react": "~18.2.0",
40
- "@types/react-dom": "~18.2.0",
41
- "react": "~18.2.0",
42
- "react-dom": "~18.2.0",
43
- "vite": "5.4.7",
44
- "@dxos/random": "0.8.3",
45
- "@dxos/react-ui": "0.8.3",
46
- "@dxos/react-ui-theme": "0.8.3",
47
- "@dxos/storybook-utils": "0.8.3"
43
+ "@types/react": "~19.2.7",
44
+ "@types/react-dom": "~19.2.3",
45
+ "react": "~19.2.3",
46
+ "react-dom": "~19.2.3",
47
+ "vite": "7.1.9",
48
+ "@dxos/storybook-utils": "0.8.4-main.1068cf700f",
49
+ "@dxos/ui-theme": "0.8.4-main.1068cf700f",
50
+ "@dxos/react-ui": "0.8.4-main.1068cf700f",
51
+ "@dxos/random": "0.8.4-main.1068cf700f"
48
52
  },
49
53
  "peerDependencies": {
50
- "react": "~18.2.0",
51
- "react-dom": "~18.2.0",
52
- "@dxos/react-ui": "0.8.3",
53
- "@dxos/react-ui-theme": "0.8.3"
54
+ "react": "~19.2.3",
55
+ "react-dom": "~19.2.3",
56
+ "@dxos/ui-theme": "0.8.4-main.1068cf700f",
57
+ "@dxos/react-ui": "0.8.4-main.1068cf700f"
54
58
  },
55
59
  "publishConfig": {
56
60
  "access": "public"
@@ -2,16 +2,52 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
5
6
  import React from 'react';
6
7
 
7
8
  import { faker } from '@dxos/random';
8
9
  import { Dialog, Icon } from '@dxos/react-ui';
9
- import { withTheme } from '@dxos/storybook-utils';
10
+ import { withTheme } from '@dxos/react-ui/testing';
10
11
 
11
12
  import { Tabs as NaturalTabs } from './Tabs';
12
13
 
13
14
  faker.seed(1234);
14
15
 
16
+ export const DefaultStory = () => {
17
+ return (
18
+ <Dialog.Root open>
19
+ <Dialog.Overlay blockAlign='start'>
20
+ <Dialog.Content size='xl'>
21
+ <NaturalTabs.Root orientation='vertical' defaultValue={Object.keys(content)[3]} defaultActivePart='list'>
22
+ <NaturalTabs.Viewport>
23
+ <NaturalTabs.Tablist>
24
+ {Object.entries(content).map(([id, { title }]) => {
25
+ return (
26
+ <NaturalTabs.Tab key={id} value={id}>
27
+ {title}
28
+ </NaturalTabs.Tab>
29
+ );
30
+ })}
31
+ </NaturalTabs.Tablist>
32
+ {Object.entries(content).map(([id, { panel }]) => {
33
+ return (
34
+ <NaturalTabs.Tabpanel key={id} value={id} classNames='m-1'>
35
+ <NaturalTabs.BackButton density='fine'>
36
+ <Icon icon='ph--arrow-left--bold' size={4} />
37
+ <span>Back to tab list</span>
38
+ </NaturalTabs.BackButton>
39
+ <p className='pli-1'>{panel}</p>
40
+ </NaturalTabs.Tabpanel>
41
+ );
42
+ })}
43
+ </NaturalTabs.Viewport>
44
+ </NaturalTabs.Root>
45
+ </Dialog.Content>
46
+ </Dialog.Overlay>
47
+ </Dialog.Root>
48
+ );
49
+ };
50
+
15
51
  const content = [...Array(24)].reduce((acc: { [key: string]: { title: string; panel: string } }, _, index) => {
16
52
  acc[`t${index}`] = {
17
53
  title: faker.commerce.productName(),
@@ -20,46 +56,15 @@ const content = [...Array(24)].reduce((acc: { [key: string]: { title: string; pa
20
56
  return acc;
21
57
  }, {});
22
58
 
23
- export default {
59
+ const meta = {
24
60
  title: 'ui/react-ui-tabs/Tabs',
25
61
  component: NaturalTabs.Root,
26
- decorators: [withTheme],
27
- // parameters: { translations },
28
- };
62
+ render: DefaultStory,
63
+ decorators: [withTheme()],
64
+ } satisfies Meta<typeof DefaultStory>;
29
65
 
30
- export const Tabs = {
31
- render: () => {
32
- return (
33
- <Dialog.Root open>
34
- <Dialog.Overlay blockAlign='start'>
35
- <Dialog.Content classNames='is-[calc(100dvw-4rem)] !max-is-full'>
36
- <NaturalTabs.Root orientation='vertical' defaultValue={Object.keys(content)[3]} defaultActivePart='list'>
37
- <NaturalTabs.Viewport>
38
- <NaturalTabs.Tablist>
39
- {Object.entries(content).map(([id, { title }]) => {
40
- return (
41
- <NaturalTabs.Tab key={id} value={id}>
42
- {title}
43
- </NaturalTabs.Tab>
44
- );
45
- })}
46
- </NaturalTabs.Tablist>
47
- {Object.entries(content).map(([id, { panel }]) => {
48
- return (
49
- <NaturalTabs.Tabpanel key={id} value={id} classNames='m-1'>
50
- <NaturalTabs.BackButton density='fine'>
51
- <Icon icon='ph--arrow-left--bold' size={4} />
52
- <span>Back to tab list</span>
53
- </NaturalTabs.BackButton>
54
- <p className='pli-1'>{panel}</p>
55
- </NaturalTabs.Tabpanel>
56
- );
57
- })}
58
- </NaturalTabs.Viewport>
59
- </NaturalTabs.Root>
60
- </Dialog.Content>
61
- </Dialog.Overlay>
62
- </Dialog.Root>
63
- );
64
- },
65
- };
66
+ export default meta;
67
+
68
+ type Story = StoryObj<typeof meta>;
69
+
70
+ export const Default: Story = {};
package/src/Tabs.tsx CHANGED
@@ -2,15 +2,29 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { useFocusFinders, useArrowNavigationGroup, useFocusableGroup } from '@fluentui/react-tabster';
5
+ import { useArrowNavigationGroup, useFocusFinders, useFocusableGroup } from '@fluentui/react-tabster';
6
6
  import { createContext } from '@radix-ui/react-context';
7
7
  import * as TabsPrimitive from '@radix-ui/react-tabs';
8
8
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
9
- import React, { type ComponentPropsWithoutRef, type MouseEvent, useCallback, useLayoutEffect, useRef } from 'react';
9
+ import React, {
10
+ Activity,
11
+ type ComponentPropsWithoutRef,
12
+ type MouseEvent,
13
+ forwardRef,
14
+ useCallback,
15
+ useLayoutEffect,
16
+ } from 'react';
10
17
 
11
- import { Button, type ButtonProps, type ThemedClassName } from '@dxos/react-ui';
18
+ import {
19
+ Button,
20
+ type ButtonProps,
21
+ IconButton,
22
+ type IconButtonProps,
23
+ type ThemedClassName,
24
+ useForwardedRef,
25
+ } from '@dxos/react-ui';
12
26
  import { useAttention } from '@dxos/react-ui-attention';
13
- import { ghostHover, ghostSelectedContainerMd, mx } from '@dxos/react-ui-theme';
27
+ import { ghostSelectedContainerMd, mx } from '@dxos/ui-theme';
14
28
 
15
29
  type TabsActivePart = 'list' | 'panel';
16
30
 
@@ -36,83 +50,90 @@ type TabsRootProps = ThemedClassName<TabsPrimitive.TabsProps> &
36
50
  defaultActivePart: TabsActivePart;
37
51
  }>;
38
52
 
39
- const TabsRoot = ({
40
- children,
41
- classNames,
42
- activePart: propsActivePart,
43
- onActivePartChange,
44
- defaultActivePart,
45
- value: propsValue,
46
- onValueChange,
47
- defaultValue,
48
- orientation = 'vertical',
49
- activationMode = 'manual',
50
- verticalVariant = 'stateful',
51
- attendableId,
52
- ...props
53
- }: TabsRootProps) => {
54
- // TODO(thure): Without these, we get Groupper/Mover `API used before initialization`, but why?
55
- const _1 = useArrowNavigationGroup();
56
- const _2 = useFocusableGroup();
57
- const [activePart = 'list', setActivePart] = useControllableState({
58
- prop: propsActivePart,
59
- onChange: onActivePartChange,
60
- defaultProp: defaultActivePart,
61
- });
62
-
63
- const [value, setValue] = useControllableState({
64
- prop: propsValue,
65
- onChange: onValueChange,
66
- defaultProp: defaultValue,
67
- });
68
-
69
- const handleValueChange = useCallback(
70
- (nextValue: string) => {
71
- setActivePart('panel');
72
- setValue(nextValue);
53
+ const TabsRoot = forwardRef<HTMLDivElement, TabsRootProps>(
54
+ (
55
+ {
56
+ children,
57
+ classNames,
58
+ activePart: propsActivePart,
59
+ onActivePartChange,
60
+ defaultActivePart,
61
+ value: propsValue,
62
+ onValueChange,
63
+ defaultValue,
64
+ orientation = 'vertical',
65
+ activationMode = 'manual',
66
+ verticalVariant = 'stateful',
67
+ attendableId,
68
+ ...props
73
69
  },
74
- [value],
75
- );
70
+ forwardedRef,
71
+ ) => {
72
+ // const tabsRoot = useRef<HTMLDivElement | null>(null);
73
+ const tabsRoot = useForwardedRef(forwardedRef);
76
74
 
77
- const { findFirstFocusable } = useFocusFinders();
78
- const tabsRoot = useRef<HTMLDivElement | null>(null);
75
+ // TODO(thure): Without these, we get Groupper/Mover `API used before initialization`, but why?
76
+ const _1 = useArrowNavigationGroup();
77
+ const _2 = useFocusableGroup();
78
+ const [activePart = 'list', setActivePart] = useControllableState({
79
+ prop: propsActivePart,
80
+ onChange: onActivePartChange,
81
+ defaultProp: defaultActivePart,
82
+ });
79
83
 
80
- useLayoutEffect(() => {
81
- if (tabsRoot.current) {
82
- findFirstFocusable(tabsRoot.current)?.focus();
83
- }
84
- }, [activePart]);
84
+ const [value, setValue] = useControllableState({
85
+ prop: propsValue,
86
+ onChange: onValueChange,
87
+ defaultProp: defaultValue,
88
+ });
85
89
 
86
- return (
87
- <TabsContextProvider
88
- orientation={orientation}
89
- activePart={activePart}
90
- setActivePart={setActivePart}
91
- value={value}
92
- attendableId={attendableId}
93
- verticalVariant={verticalVariant}
94
- >
95
- <TabsPrimitive.Root
96
- activationMode={activationMode}
97
- data-active={activePart}
90
+ const handleValueChange = useCallback(
91
+ (nextValue: string) => {
92
+ setActivePart('panel');
93
+ setValue(nextValue);
94
+ },
95
+ [value],
96
+ );
97
+
98
+ const { findFirstFocusable } = useFocusFinders();
99
+
100
+ useLayoutEffect(() => {
101
+ if (tabsRoot.current) {
102
+ findFirstFocusable(tabsRoot.current)?.focus();
103
+ }
104
+ }, [activePart]);
105
+
106
+ return (
107
+ <TabsContextProvider
98
108
  orientation={orientation}
99
- {...props}
109
+ activePart={activePart}
110
+ setActivePart={setActivePart}
100
111
  value={value}
101
- onValueChange={handleValueChange}
102
- className={mx(
103
- 'overflow-hidden',
104
- orientation === 'vertical' &&
105
- verticalVariant === 'stateful' &&
106
- '[&[data-active=list]_[role=tabpanel]]:invisible @md:[&[data-active=list]_[role=tabpanel]]:visible',
107
- classNames,
108
- )}
109
- ref={tabsRoot}
112
+ attendableId={attendableId}
113
+ verticalVariant={verticalVariant}
110
114
  >
111
- {children}
112
- </TabsPrimitive.Root>
113
- </TabsContextProvider>
114
- );
115
- };
115
+ <TabsPrimitive.Root
116
+ activationMode={activationMode}
117
+ data-active={activePart}
118
+ orientation={orientation}
119
+ {...props}
120
+ value={value}
121
+ onValueChange={handleValueChange}
122
+ className={mx(
123
+ 'overflow-hidden',
124
+ orientation === 'vertical' &&
125
+ verticalVariant === 'stateful' &&
126
+ '[&[data-active=list]_[role=tabpanel]]:invisible @md:[&[data-active=list]_[role=tabpanel]]:visible',
127
+ classNames,
128
+ )}
129
+ ref={tabsRoot}
130
+ >
131
+ {children}
132
+ </TabsPrimitive.Root>
133
+ </TabsContextProvider>
134
+ );
135
+ },
136
+ );
116
137
 
117
138
  type TabsViewportProps = ThemedClassName<ComponentPropsWithoutRef<'div'>>;
118
139
 
@@ -144,6 +165,7 @@ const TabsTablist = ({ children, classNames, ...props }: TabsTablistProps) => {
144
165
  return (
145
166
  <TabsPrimitive.List
146
167
  {...props}
168
+ data-arrow-keys={orientation === 'vertical' ? 'up down' : 'left right'}
147
169
  className={mx(
148
170
  'max-bs-full is-full',
149
171
  // NOTE: Padding should be common to Toolbar.
@@ -185,6 +207,7 @@ type TabsTabProps = ButtonProps & Pick<TabsPrimitive.TabsTriggerProps, 'value'>;
185
207
  const TabsTab = ({ value, classNames, children, onClick, ...props }: TabsTabProps) => {
186
208
  const { setActivePart, orientation, value: contextValue, attendableId } = useTabsContext('TabsTab');
187
209
  const { hasAttention } = useAttention(attendableId);
210
+
188
211
  const handleClick = useCallback(
189
212
  // NOTE: This handler is only called if the tab is *already active*.
190
213
  (event: MouseEvent<HTMLButtonElement>) => {
@@ -204,10 +227,8 @@ const TabsTab = ({ value, classNames, children, onClick, ...props }: TabsTabProp
204
227
  {...props}
205
228
  onClick={handleClick}
206
229
  classNames={[
207
- 'pli-2 rounded-sm',
208
230
  orientation === 'vertical' && 'block justify-start text-start is-full',
209
231
  orientation === 'vertical' && ghostSelectedContainerMd,
210
- ghostHover,
211
232
  classNames,
212
233
  ]}
213
234
  >
@@ -217,13 +238,50 @@ const TabsTab = ({ value, classNames, children, onClick, ...props }: TabsTabProp
217
238
  );
218
239
  };
219
240
 
241
+ type TabsIconTabProps = IconButtonProps & Pick<TabsPrimitive.TabsTriggerProps, 'value'>;
242
+
243
+ const TabsIconTab = ({ value, classNames, onClick, ...props }: TabsIconTabProps) => {
244
+ const { setActivePart, orientation, value: contextValue, attendableId } = useTabsContext('TabsTab');
245
+ const { hasAttention } = useAttention(attendableId);
246
+
247
+ // NOTE: This handler is only called if the tab is *already active*.
248
+ const handleClick = useCallback(
249
+ (event: MouseEvent<HTMLButtonElement>) => {
250
+ setActivePart('panel');
251
+ onClick?.(event);
252
+ },
253
+ [setActivePart, onClick],
254
+ );
255
+
256
+ return (
257
+ <TabsPrimitive.Trigger value={value} asChild>
258
+ <IconButton
259
+ density='fine'
260
+ variant={
261
+ orientation === 'horizontal' && contextValue === value ? (hasAttention ? 'primary' : 'default') : 'ghost'
262
+ }
263
+ {...props}
264
+ onClick={handleClick}
265
+ classNames={[
266
+ orientation === 'vertical' && 'justify-start text-start is-full',
267
+ orientation === 'vertical' && ghostSelectedContainerMd,
268
+ classNames,
269
+ ]}
270
+ />
271
+ </TabsPrimitive.Trigger>
272
+ );
273
+ };
274
+
220
275
  type TabsTabpanelProps = ThemedClassName<TabsPrimitive.TabsContentProps>;
221
276
 
222
277
  const TabsTabpanel = ({ classNames, children, ...props }: TabsTabpanelProps) => {
278
+ const { value: contextValue } = useTabsContext('TabsTab');
223
279
  return (
224
- <TabsPrimitive.Content {...props} className={mx('dx-focus-ring-inset-over-all', classNames)}>
225
- {children}
226
- </TabsPrimitive.Content>
280
+ <Activity mode={contextValue === props.value ? 'visible' : 'hidden'}>
281
+ <TabsPrimitive.Content {...props} className={mx('dx-focus-ring-inset-over-all', classNames)}>
282
+ {children}
283
+ </TabsPrimitive.Content>
284
+ </Activity>
227
285
  );
228
286
  };
229
287
 
@@ -233,6 +291,7 @@ export const Tabs = {
233
291
  Root: TabsRoot,
234
292
  Tablist: TabsTablist,
235
293
  Tab: TabsTab,
294
+ IconTab: TabsIconTab,
236
295
  TabPrimitive: TabsPrimitive.Trigger,
237
296
  TabGroupHeading: TabsTabGroupHeading,
238
297
  Tabpanel: TabsTabpanel,