Skip to content

Commit fd22070

Browse files
committed
feat(primitives): add Tabs component
1 parent 43e575f commit fd22070

File tree

26 files changed

+557
-0
lines changed

26 files changed

+557
-0
lines changed

packages/primitives/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export * as Link from './link';
1010
export * as Popover from './popover';
1111
export * as Primitive from './primitive';
1212
export * as Spinner from './spinner';
13+
export * as Tabs from './tabs';
1314
export * as Toggle from './toggle';
1415
export * as VisuallyHidden from './visually-hidden';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type { TabsRootProps as RootProps } from './tabs-root';
2+
export type { TabsListProps as ListProps } from './tabs-list';
3+
export type { TabsTriggerProps as TriggerProps } from './tabs-trigger';
4+
export type { TabsContentProps as ContentProps } from './tabs-content';
5+
6+
export { TabsRoot as Root } from './tabs-root';
7+
export { TabsList as List } from './tabs-list';
8+
export { TabsTrigger as Trigger } from './tabs-trigger';
9+
export { TabsContent as Content } from './tabs-content';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { TabsContentProps } from './tabs-content.types';
2+
export { TabsContent } from './tabs-content';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { TabsContentProps } from './tabs-content.types';
2+
import { component$, useContext, Slot } from '@builder.io/qwik';
3+
import { TabsContext } from '../tabs-context';
4+
5+
/**
6+
* Contains the content associated with each trigger.
7+
* This component is based on the `div` element.
8+
*/
9+
export const TabsContent = component$<TabsContentProps>((props) => {
10+
const { as, value, ...others } = props;
11+
12+
const { tabsValue, tabsId, orientation } = useContext(TabsContext);
13+
14+
const Component = as || 'div';
15+
16+
return (
17+
<Component
18+
role="tabpanel"
19+
tabIndex={0}
20+
hidden={value !== tabsValue.value}
21+
id={`qwik-primitives-tabs-${tabsId}-content-${value}`}
22+
aria-labelledby={`qwik-primitives-tabs-${tabsId}-trigger-${value}`}
23+
data-qwik-primitives-tabs-content=""
24+
data-scope="tabs"
25+
data-part="content"
26+
data-state={value === tabsValue.value ? 'active' : 'inactive'}
27+
data-orientation={orientation}
28+
{...others}
29+
>
30+
<Slot />
31+
</Component>
32+
);
33+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { PropsOf, FunctionComponent, CSSProperties } from '@builder.io/qwik';
2+
3+
export interface TabsContentProps extends PropsOf<'div'> {
4+
/**
5+
* Change the default rendered element for the one passed as, merging their props and behavior.
6+
*
7+
* Read our [Composition](https://github.com/ZAHON/qwik-primitives/blob/main/packages/primitives/docs/composition.md) guide for more details.
8+
*/
9+
as?: FunctionComponent;
10+
11+
/**
12+
* A unique value that associates the content with a trigger.
13+
*/
14+
value: string;
15+
16+
/**
17+
* The inline style for the element.
18+
*/
19+
style?: CSSProperties;
20+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { TabsContextValue } from './tabs-context.types';
2+
export { TabsContext } from './tabs-context';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type { TabsContextValue } from './tabs-context.types';
2+
import { createContextId } from '@builder.io/qwik';
3+
4+
export const TabsContext = createContextId<TabsContextValue>('qwik-primitives-tabs-context');
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ReadonlySignal, QRL, Signal } from '@builder.io/qwik';
2+
3+
export interface TabsContextValue {
4+
/**
5+
* The controlled value of the tab to activate.
6+
*/
7+
tabsValue: ReadonlySignal<string>;
8+
9+
/**
10+
* The function that allow change controlled value of the tab to activate.
11+
*/
12+
setTabsValue$: QRL<(open: string) => void>;
13+
14+
/**
15+
* The unique id of the tabs.
16+
*/
17+
tabsId: string;
18+
19+
/**
20+
* The reference to tabs list DOM element.
21+
*/
22+
listRef: Signal<HTMLElement | undefined>;
23+
24+
/**
25+
* When `true`, keyboard navigation will loop from last tab to first, and vice versa.
26+
*/
27+
isLoop: Signal<boolean | undefined>;
28+
29+
/**
30+
* The current tab stop id.
31+
*/
32+
currentTabStopId: Signal<string | undefined>;
33+
34+
isTabbingBackOut: Signal<boolean>;
35+
36+
/**
37+
* The orientation of the component.
38+
*/
39+
orientation: 'horizontal' | 'vertical';
40+
41+
/**
42+
* When `"automatic"`, tabs are activated when receiving focus.
43+
* When `"manual"`, tabs are activated when clicked.
44+
*/
45+
activationMode: 'automatic' | 'manual';
46+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { TabsListProps } from './tabs-list.types';
2+
export { TabsList } from './tabs-list';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { TabsListProps } from './tabs-list.types';
2+
import { component$, useContext, useSignal, useTask$, $, Slot } from '@builder.io/qwik';
3+
import { getTriggers, focusFirstTrigger } from '../utilities';
4+
import { composeRefs } from '@/utilities';
5+
import { TabsContext } from '../tabs-context';
6+
7+
/**
8+
* Contains the triggers that are aligned along the edge of the active content.
9+
* This component is based on the `div` element.
10+
*/
11+
export const TabsList = component$<TabsListProps>((props) => {
12+
const { as, ref, loop = true, onMouseDown$, onFocus$, style, ...others } = props;
13+
14+
const { listRef, isLoop, currentTabStopId, isTabbingBackOut, orientation } = useContext(TabsContext);
15+
16+
const isClickFocus = useSignal(false);
17+
const shouldHaveOutline = useSignal(false);
18+
19+
useTask$(({ track }) => {
20+
track(() => loop);
21+
22+
isLoop.value = loop;
23+
});
24+
25+
const handleMouseDown$ = $(() => {
26+
isClickFocus.value = true;
27+
});
28+
29+
const handleFocus$ = $((event: FocusEvent, currentTarget: HTMLElement) => {
30+
// We normally wouldn't need this check, because we already check
31+
// that the focus is on the current target and not bubbling to it.
32+
// We do this because Safari doesn't focus buttons when clicked, and
33+
// instead, the wrapper will get focused and not through a bubbling event.
34+
const isKeyboardFocus = !isClickFocus.value;
35+
36+
if (event.target === currentTarget && isKeyboardFocus && !isTabbingBackOut.value) {
37+
const triggers = getTriggers(listRef.value).filter(({ focusable }) => focusable);
38+
39+
if (triggers.length) {
40+
shouldHaveOutline.value = false;
41+
42+
const activeTrigger = triggers.find(({ active }) => active);
43+
const currentTrigger = triggers.find(({ id }) => id === currentTabStopId.value);
44+
const candidateTriggers = [activeTrigger, currentTrigger, ...triggers].filter(Boolean) as typeof triggers;
45+
const candidateNodes = candidateTriggers.map(({ ref }) => ref);
46+
47+
focusFirstTrigger(candidateNodes);
48+
} else {
49+
shouldHaveOutline.value = true;
50+
}
51+
}
52+
53+
isClickFocus.value = false;
54+
});
55+
56+
const Component = as || 'div';
57+
58+
return (
59+
<Component
60+
ref={composeRefs([ref, listRef])}
61+
role="tablist"
62+
tabIndex={isTabbingBackOut.value ? -1 : 0}
63+
aria-orientation={orientation}
64+
data-qwik-primitives-tabs-list=""
65+
data-scope="tabs"
66+
data-part="list"
67+
data-orientation={orientation}
68+
onMouseDown$={[onMouseDown$, handleMouseDown$]}
69+
onFocus$={[onFocus$, handleFocus$]}
70+
style={{ outline: shouldHaveOutline.value ? undefined : 'none', ...style }}
71+
{...others}
72+
>
73+
<Slot />
74+
</Component>
75+
);
76+
});

0 commit comments

Comments
 (0)