@xsolla/xui-tabs 0.141.1 → 0.147.0-pr265.1777518947

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/README.md ADDED
@@ -0,0 +1,437 @@
1
+ # Tabs
2
+
3
+ A cross-platform React tabs component for organizing content into multiple panels that users can switch between. Implements WAI-ARIA tablist pattern for accessibility.
4
+
5
+ **Variants:**
6
+ - **Line** (default): Traditional underlined tabs with bottom border indicator
7
+ - **Segmented**: Button-group style segmented control with sliding active indicator
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @xsolla/xui-tabs
13
+ # or
14
+ yarn add @xsolla/xui-tabs
15
+ ```
16
+
17
+ ## Demo
18
+
19
+ ### Basic Tabs
20
+
21
+ ```tsx
22
+ import * as React from 'react';
23
+ import { Tabs, TabPanel } from '@xsolla/xui-tabs';
24
+
25
+ export default function BasicTabs() {
26
+ const [activeTab, setActiveTab] = React.useState('tab1');
27
+
28
+ const tabs = [
29
+ { id: 'tab1', label: 'Overview' },
30
+ { id: 'tab2', label: 'Features' },
31
+ { id: 'tab3', label: 'Pricing' },
32
+ ];
33
+
34
+ return (
35
+ <div>
36
+ <Tabs
37
+ id="basic-tabs"
38
+ tabs={tabs}
39
+ activeTabId={activeTab}
40
+ onChange={setActiveTab}
41
+ />
42
+ <TabPanel id="tab1" tabsId="basic-tabs" hidden={activeTab !== 'tab1'}>
43
+ <p>Overview content goes here.</p>
44
+ </TabPanel>
45
+ <TabPanel id="tab2" tabsId="basic-tabs" hidden={activeTab !== 'tab2'}>
46
+ <p>Features content goes here.</p>
47
+ </TabPanel>
48
+ <TabPanel id="tab3" tabsId="basic-tabs" hidden={activeTab !== 'tab3'}>
49
+ <p>Pricing content goes here.</p>
50
+ </TabPanel>
51
+ </div>
52
+ );
53
+ }
54
+ ```
55
+
56
+ ### Tabs with Icons
57
+
58
+ ```tsx
59
+ import * as React from 'react';
60
+ import { Tabs } from '@xsolla/xui-tabs';
61
+ import { Home, User, Settings } from '@xsolla/xui-icons';
62
+
63
+ export default function TabsWithIcons() {
64
+ const [activeTab, setActiveTab] = React.useState('home');
65
+
66
+ const tabs = [
67
+ { id: 'home', label: 'Home', icon: <Home size={16} /> },
68
+ { id: 'profile', label: 'Profile', icon: <User size={16} /> },
69
+ { id: 'settings', label: 'Settings', icon: <Settings size={16} /> },
70
+ ];
71
+
72
+ return (
73
+ <Tabs
74
+ tabs={tabs}
75
+ activeTabId={activeTab}
76
+ onChange={setActiveTab}
77
+ />
78
+ );
79
+ }
80
+ ```
81
+
82
+ ### Tabs with Counter
83
+
84
+ ```tsx
85
+ import * as React from 'react';
86
+ import { Tabs } from '@xsolla/xui-tabs';
87
+
88
+ export default function TabsWithCounter() {
89
+ const [activeTab, setActiveTab] = React.useState('inbox');
90
+
91
+ const tabs = [
92
+ { id: 'inbox', label: 'Inbox', counter: 12 },
93
+ { id: 'sent', label: 'Sent', counter: 5 },
94
+ { id: 'drafts', label: 'Drafts', counter: 3 },
95
+ { id: 'spam', label: 'Spam', badge: true },
96
+ ];
97
+
98
+ return (
99
+ <Tabs
100
+ tabs={tabs}
101
+ activeTabId={activeTab}
102
+ onChange={setActiveTab}
103
+ />
104
+ );
105
+ }
106
+ ```
107
+
108
+ ### Tabs Sizes
109
+
110
+ ```tsx
111
+ import * as React from 'react';
112
+ import { Tabs } from '@xsolla/xui-tabs';
113
+
114
+ export default function TabsSizes() {
115
+ const tabs = [
116
+ { id: 'a', label: 'Tab A' },
117
+ { id: 'b', label: 'Tab B' },
118
+ { id: 'c', label: 'Tab C' },
119
+ ];
120
+
121
+ return (
122
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
123
+ <Tabs tabs={tabs} activeTabId="a" size="sm" />
124
+ <Tabs tabs={tabs} activeTabId="a" size="md" />
125
+ <Tabs tabs={tabs} activeTabId="a" size="lg" />
126
+ <Tabs tabs={tabs} activeTabId="a" size="xl" />
127
+ </div>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ### Segmented Variant
133
+
134
+ A button-group style segmented control with a sliding active indicator animation.
135
+
136
+ ```tsx
137
+ import * as React from 'react';
138
+ import { Tabs } from '@xsolla/xui-tabs';
139
+
140
+ export default function SegmentedTabs() {
141
+ const [activeTab, setActiveTab] = React.useState('daily');
142
+
143
+ const tabs = [
144
+ { id: 'daily', label: 'Daily' },
145
+ { id: 'weekly', label: 'Weekly' },
146
+ { id: 'monthly', label: 'Monthly' },
147
+ ];
148
+
149
+ return (
150
+ <Tabs
151
+ tabs={tabs}
152
+ activeTabId={activeTab}
153
+ onChange={setActiveTab}
154
+ variant="segmented"
155
+ />
156
+ );
157
+ }
158
+ ```
159
+
160
+ ### Stretched Segmented Tabs
161
+
162
+ For the segmented variant, the `stretched` prop distributes items at equal width across the container. Has no effect on the line variant — line tabs always span the container width and rely on `alignLeft` for positioning.
163
+
164
+ ```tsx
165
+ import * as React from 'react';
166
+ import { Tabs } from '@xsolla/xui-tabs';
167
+
168
+ export default function StretchedTabs() {
169
+ const [activeTab, setActiveTab] = React.useState('tab1');
170
+
171
+ const tabs = [
172
+ { id: 'tab1', label: 'Log in' },
173
+ { id: 'tab2', label: 'Sign up' },
174
+ ];
175
+
176
+ return (
177
+ <Tabs
178
+ tabs={tabs}
179
+ activeTabId={activeTab}
180
+ onChange={setActiveTab}
181
+ variant="segmented"
182
+ stretched
183
+ />
184
+ );
185
+ }
186
+ ```
187
+
188
+ ## Anatomy
189
+
190
+ Import the components and compose them:
191
+
192
+ ```jsx
193
+ import { Tabs, TabPanel } from '@xsolla/xui-tabs';
194
+
195
+ // Tab list
196
+ <Tabs
197
+ id="my-tabs" // Unique ID for ARIA
198
+ tabs={tabItems} // Array of tab definitions
199
+ activeTabId={activeId} // Currently active tab
200
+ onChange={handleChange} // Tab selection handler
201
+ size="md" // Size variant
202
+ variant="line" // Visual variant: "line" | "segmented"
203
+ stretched={false} // Equal-width items (segmented variant only)
204
+ alignLeft={true} // Left-anchor vs centre (line variant only)
205
+ activateOnFocus={true} // Auto-activate on focus
206
+ />
207
+
208
+ // Tab panels
209
+ <TabPanel
210
+ id="tab-id" // Matches tab.id
211
+ tabsId="my-tabs" // Parent Tabs id
212
+ hidden={!isActive} // Visibility control
213
+ >
214
+ Panel content
215
+ </TabPanel>
216
+ ```
217
+
218
+ ## Examples
219
+
220
+ ### Disabled Tab
221
+
222
+ ```tsx
223
+ import * as React from 'react';
224
+ import { Tabs } from '@xsolla/xui-tabs';
225
+
226
+ export default function DisabledTab() {
227
+ const [activeTab, setActiveTab] = React.useState('active');
228
+
229
+ const tabs = [
230
+ { id: 'active', label: 'Active Tab' },
231
+ { id: 'disabled', label: 'Disabled Tab', disabled: true },
232
+ { id: 'another', label: 'Another Tab' },
233
+ ];
234
+
235
+ return (
236
+ <Tabs
237
+ tabs={tabs}
238
+ activeTabId={activeTab}
239
+ onChange={setActiveTab}
240
+ />
241
+ );
242
+ }
243
+ ```
244
+
245
+ ### Manual Activation
246
+
247
+ ```tsx
248
+ import * as React from 'react';
249
+ import { Tabs } from '@xsolla/xui-tabs';
250
+
251
+ export default function ManualActivation() {
252
+ const [activeTab, setActiveTab] = React.useState('tab1');
253
+
254
+ const tabs = [
255
+ { id: 'tab1', label: 'First' },
256
+ { id: 'tab2', label: 'Second' },
257
+ { id: 'tab3', label: 'Third' },
258
+ ];
259
+
260
+ return (
261
+ <div>
262
+ <p>Use Arrow keys to navigate, Enter/Space to activate</p>
263
+ <Tabs
264
+ tabs={tabs}
265
+ activeTabId={activeTab}
266
+ onChange={setActiveTab}
267
+ activateOnFocus={false}
268
+ />
269
+ </div>
270
+ );
271
+ }
272
+ ```
273
+
274
+ ### Full Example with Content
275
+
276
+ ```tsx
277
+ import * as React from 'react';
278
+ import { Tabs, TabPanel } from '@xsolla/xui-tabs';
279
+ import { Settings } from '@xsolla/xui-icons';
280
+ import { LayoutDashboard, BarChart3, FileText } from '@xsolla/xui-icons-base';
281
+
282
+ export default function FullTabsExample() {
283
+ const [activeTab, setActiveTab] = React.useState('dashboard');
284
+
285
+ const tabs = [
286
+ { id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={16} /> },
287
+ { id: 'analytics', label: 'Analytics', icon: <BarChart3 size={16} />, counter: 5 },
288
+ { id: 'reports', label: 'Reports', icon: <FileText size={16} /> },
289
+ { id: 'settings', label: 'Settings', icon: <Settings size={16} /> },
290
+ ];
291
+
292
+ return (
293
+ <div style={{ width: '100%' }}>
294
+ <Tabs
295
+ id="main-tabs"
296
+ tabs={tabs}
297
+ activeTabId={activeTab}
298
+ onChange={setActiveTab}
299
+ size="md"
300
+ />
301
+
302
+ <div style={{ padding: 24, border: '1px solid #eee', borderTop: 'none' }}>
303
+ <TabPanel id="dashboard" tabsId="main-tabs" hidden={activeTab !== 'dashboard'}>
304
+ <h3>Dashboard</h3>
305
+ <p>Welcome to your dashboard overview.</p>
306
+ </TabPanel>
307
+
308
+ <TabPanel id="analytics" tabsId="main-tabs" hidden={activeTab !== 'analytics'}>
309
+ <h3>Analytics</h3>
310
+ <p>View your analytics and metrics here.</p>
311
+ </TabPanel>
312
+
313
+ <TabPanel id="reports" tabsId="main-tabs" hidden={activeTab !== 'reports'}>
314
+ <h3>Reports</h3>
315
+ <p>Generate and view reports.</p>
316
+ </TabPanel>
317
+
318
+ <TabPanel id="settings" tabsId="main-tabs" hidden={activeTab !== 'settings'}>
319
+ <h3>Settings</h3>
320
+ <p>Configure your preferences.</p>
321
+ </TabPanel>
322
+ </div>
323
+ </div>
324
+ );
325
+ }
326
+ ```
327
+
328
+ ## API Reference
329
+
330
+ ### Tabs
331
+
332
+ The tab list component.
333
+
334
+ **Tabs Props:**
335
+
336
+ | Prop | Type | Default | Description |
337
+ | :--- | :--- | :------ | :---------- |
338
+ | tabs | `TabItemType[]` | - | **Required.** Array of tab definitions. |
339
+ | activeTabId | `string` | - | Currently active tab ID. |
340
+ | onChange | `(id: string) => void` | - | Callback when tab selection changes. |
341
+ | size | `"xl" \| "lg" \| "md" \| "sm"` | `"md"` | Size of the tabs. |
342
+ | variant | `"line" \| "segmented"` | `"line"` | Visual variant. Line shows underlined tabs; segmented shows button-group style. |
343
+ | stretched | `boolean` | `false` | Segmented variant only: distribute items at equal width. No effect on line variant. |
344
+ | alignLeft | `boolean` | `true` | Left vs center alignment (line variant only). |
345
+ | activateOnFocus | `boolean` | `true` | Auto-activate tab on focus. |
346
+ | id | `string` | - | Unique ID for ARIA association. |
347
+ | aria-label | `string` | - | Accessible label for tab list. |
348
+ | aria-labelledby | `string` | - | ID of labeling element. |
349
+ | testID | `string` | - | Test identifier. |
350
+
351
+ **TabItemType:**
352
+
353
+ ```typescript
354
+ interface TabItemType {
355
+ id: string; // Unique tab identifier
356
+ label: string; // Display label
357
+ icon?: ReactNode; // Optional icon
358
+ counter?: string | number; // Optional counter badge
359
+ counterPalette?: "brand" | "tertiary" | "default"; // Counter colour (line variant). Default "brand".
360
+ badge?: boolean | string | number; // Optional badge
361
+ disabled?: boolean; // Whether tab is disabled
362
+ "aria-label"?: string; // Custom accessible label
363
+ }
364
+ ```
365
+
366
+ ---
367
+
368
+ ### TabPanel
369
+
370
+ Container for tab content.
371
+
372
+ **TabPanel Props:**
373
+
374
+ | Prop | Type | Default | Description |
375
+ | :--- | :--- | :------ | :---------- |
376
+ | id | `string` | - | **Required.** Matches corresponding tab.id. |
377
+ | tabsId | `string` | - | **Required.** Parent Tabs component id. |
378
+ | hidden | `boolean` | - | Whether panel is hidden. |
379
+ | children | `ReactNode` | - | Panel content. |
380
+ | aria-label | `string` | - | Accessible label. |
381
+ | testID | `string` | - | Test identifier. |
382
+
383
+ ## Keyboard Navigation
384
+
385
+ | Key | Action |
386
+ | :-- | :----- |
387
+ | Arrow Right/Down | Move to next enabled tab |
388
+ | Arrow Left/Up | Move to previous enabled tab |
389
+ | Home | Jump to first enabled tab |
390
+ | End | Jump to last enabled tab |
391
+ | Enter/Space | Activate tab (when activateOnFocus is false) |
392
+
393
+ ## Theming
394
+
395
+ Tabs uses the design system theme for colors:
396
+
397
+ ### Line Variant
398
+
399
+ ```typescript
400
+ // Colors accessed via theme
401
+ theme.colors.content.primary // Tab text (default, hover, active); counterPalette="default"
402
+ theme.colors.content.tertiary // counterPalette="tertiary"
403
+ theme.colors.control.text.disable // Disabled tab text and disabled counter
404
+ theme.colors.content.brand.primary // counterPalette="brand" (default)
405
+ theme.colors.border.primary // Active tab indicator (bottom border)
406
+ theme.colors.border.secondary // Container bottom border
407
+ theme.colors.overlay.mono // Hover background
408
+ ```
409
+
410
+ ### Segmented Variant
411
+
412
+ ```typescript
413
+ // Colors accessed via theme
414
+ theme.colors.control.segmented.bg // Container background
415
+ theme.colors.control.segmented.bgHover // Tab hover background
416
+ theme.colors.control.segmented.bgActive // Active tab indicator background
417
+ theme.colors.control.segmented.text // Tab text
418
+ theme.colors.control.segmented.textDisable // Disabled tab text
419
+ ```
420
+
421
+ ## Accessibility
422
+
423
+ - Implements WAI-ARIA tablist pattern
424
+ - Uses `role="tablist"`, `role="tab"`, `role="tabpanel"`
425
+ - `aria-selected` indicates active tab
426
+ - `aria-controls` links tabs to panels
427
+ - `aria-labelledby` links panels to tabs
428
+ - Full keyboard navigation support
429
+ - Focus management follows ARIA best practices
430
+ - Segmented variant sliding indicator is marked with `aria-hidden`
431
+
432
+ ## Platform Support
433
+
434
+ Both variants support web and React Native platforms:
435
+
436
+ - **Web**: Segmented variant includes smooth sliding animation for the active indicator
437
+ - **React Native**: Falls back to static background without animation
@@ -10,6 +10,8 @@ interface TabItemType {
10
10
  icon?: React.ReactNode;
11
11
  /** Optional counter to display after the label */
12
12
  counter?: string | number;
13
+ /** Counter colour palette (line variant only). Defaults to "brand". */
14
+ counterPalette?: "brand" | "tertiary" | "default";
13
15
  /** Optional badge to display */
14
16
  badge?: boolean | string | number;
15
17
  /** Whether the tab is disabled */
@@ -30,7 +32,7 @@ interface TabsProps extends ThemeOverrideProps {
30
32
  variant?: "line" | "segmented";
31
33
  /** Whether to align tabs to the left (only for line variant) */
32
34
  alignLeft?: boolean;
33
- /** Whether the component should stretch to fill its container */
35
+ /** Segmented variant only: distribute items at equal width across the container */
34
36
  stretched?: boolean;
35
37
  /** Accessible label for the tab list */
36
38
  "aria-label"?: string;
package/native/index.d.ts CHANGED
@@ -10,6 +10,8 @@ interface TabItemType {
10
10
  icon?: React.ReactNode;
11
11
  /** Optional counter to display after the label */
12
12
  counter?: string | number;
13
+ /** Counter colour palette (line variant only). Defaults to "brand". */
14
+ counterPalette?: "brand" | "tertiary" | "default";
13
15
  /** Optional badge to display */
14
16
  badge?: boolean | string | number;
15
17
  /** Whether the tab is disabled */
@@ -30,7 +32,7 @@ interface TabsProps extends ThemeOverrideProps {
30
32
  variant?: "line" | "segmented";
31
33
  /** Whether to align tabs to the left (only for line variant) */
32
34
  alignLeft?: boolean;
33
- /** Whether the component should stretch to fill its container */
35
+ /** Segmented variant only: distribute items at equal width across the container */
34
36
  stretched?: boolean;
35
37
  /** Accessible label for the tab list */
36
38
  "aria-label"?: string;
package/native/index.js CHANGED
@@ -565,8 +565,7 @@ var Tabs = ({
565
565
  flexDirection: "row",
566
566
  alignItems: "flex-end",
567
567
  justifyContent: alignLeft ? "flex-start" : "center",
568
- width: stretched ? "100%" : isWeb ? "fit-content" : void 0,
569
- alignSelf: !stretched && !isWeb ? "flex-start" : void 0,
568
+ width: "100%",
570
569
  height: lineStyles.height,
571
570
  borderBottomWidth: 1,
572
571
  borderBottomColor: theme.colors.border.secondary,
@@ -584,7 +583,9 @@ var Tabs = ({
584
583
  const handleFocus = () => {
585
584
  setFocusedIndex(index);
586
585
  };
587
- const textColor = isDisabled ? theme.colors.content.tertiary : isActive ? theme.colors.content.primary : theme.colors.content.tertiary;
586
+ const textColor = isDisabled ? theme.colors.control.text.disable : theme.colors.content.primary;
587
+ const counterPaletteColor = tab.counterPalette === "default" ? theme.colors.content.primary : tab.counterPalette === "tertiary" ? theme.colors.content.tertiary : theme.colors.content.brand.primary;
588
+ const counterColor = isDisabled ? theme.colors.control.text.disable : counterPaletteColor;
588
589
  const borderBottomColor = isActive ? theme.colors.border.primary : "transparent";
589
590
  const borderBottomWidth = isActive ? 2 : 0;
590
591
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
@@ -612,6 +613,8 @@ var Tabs = ({
612
613
  justifyContent: "center",
613
614
  gap: lineStyles.gap,
614
615
  position: "relative",
616
+ borderTopLeftRadius: 2,
617
+ borderTopRightRadius: 2,
615
618
  borderBottomWidth,
616
619
  borderBottomColor,
617
620
  borderStyle: isActive ? "solid" : "none",
@@ -632,15 +635,17 @@ var Tabs = ({
632
635
  {
633
636
  color: textColor,
634
637
  fontSize: lineStyles.fontSize,
635
- fontWeight: isActive ? "600" : "500",
638
+ lineHeight: lineStyles.lineHeight,
639
+ fontWeight: "500",
636
640
  children: tab.label
637
641
  }
638
642
  ),
639
643
  tab.counter !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Box, { marginLeft: 2, "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
640
644
  Text,
641
645
  {
642
- color: theme.colors.content.brand.primary,
646
+ color: counterColor,
643
647
  fontSize: lineStyles.fontSize,
648
+ lineHeight: lineStyles.lineHeight,
644
649
  fontWeight: "500",
645
650
  "aria-label": `${tab.counter} items`,
646
651
  children: tab.counter