@zag-js/tabs 0.9.2 → 0.10.1
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 +11 -10
- package/src/index.ts +4 -0
- package/src/tabs.anatomy.ts +4 -0
- package/src/tabs.connect.ts +168 -0
- package/src/tabs.dom.ts +59 -0
- package/src/tabs.machine.ts +256 -0
- package/src/tabs.types.ts +130 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zag-js/tabs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Core logic for the tabs widget implemented as a state machine",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"repository": "https://github.com/chakra-ui/zag/tree/main/packages/tabs",
|
|
18
18
|
"sideEffects": false,
|
|
19
19
|
"files": [
|
|
20
|
-
"dist
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
21
22
|
],
|
|
22
23
|
"publishConfig": {
|
|
23
24
|
"access": "public"
|
|
@@ -26,14 +27,14 @@
|
|
|
26
27
|
"url": "https://github.com/chakra-ui/zag/issues"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@zag-js/anatomy": "0.
|
|
30
|
-
"@zag-js/dom-query": "0.
|
|
31
|
-
"@zag-js/dom-event": "0.
|
|
32
|
-
"@zag-js/element-rect": "0.
|
|
33
|
-
"@zag-js/tabbable": "0.
|
|
34
|
-
"@zag-js/utils": "0.
|
|
35
|
-
"@zag-js/core": "0.
|
|
36
|
-
"@zag-js/types": "0.
|
|
30
|
+
"@zag-js/anatomy": "0.10.1",
|
|
31
|
+
"@zag-js/dom-query": "0.10.1",
|
|
32
|
+
"@zag-js/dom-event": "0.10.1",
|
|
33
|
+
"@zag-js/element-rect": "0.10.1",
|
|
34
|
+
"@zag-js/tabbable": "0.10.1",
|
|
35
|
+
"@zag-js/utils": "0.10.1",
|
|
36
|
+
"@zag-js/core": "0.10.1",
|
|
37
|
+
"@zag-js/types": "0.10.1"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"clean-package": "2.2.0"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { EventKeyMap, getEventKey } from "@zag-js/dom-event"
|
|
2
|
+
import { dataAttr, isSafari } from "@zag-js/dom-query"
|
|
3
|
+
import type { NormalizeProps, PropTypes } from "@zag-js/types"
|
|
4
|
+
import { parts } from "./tabs.anatomy"
|
|
5
|
+
import { dom } from "./tabs.dom"
|
|
6
|
+
import type { ContentProps, Send, State, TriggerProps } from "./tabs.types"
|
|
7
|
+
|
|
8
|
+
export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
|
|
9
|
+
const translations = state.context.translations
|
|
10
|
+
const isFocused = state.matches("focused")
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
/**
|
|
14
|
+
* The current value of the tabs.
|
|
15
|
+
*/
|
|
16
|
+
value: state.context.value,
|
|
17
|
+
/**
|
|
18
|
+
* The value of the tab that is currently focused.
|
|
19
|
+
*/
|
|
20
|
+
focusedValue: state.context.focusedValue,
|
|
21
|
+
/**
|
|
22
|
+
* The previous values of the tabs in sequence of selection.
|
|
23
|
+
*/
|
|
24
|
+
previousValues: Array.from(state.context.previousValues),
|
|
25
|
+
/**
|
|
26
|
+
* Sets the value of the tabs.
|
|
27
|
+
*/
|
|
28
|
+
setValue(value: string) {
|
|
29
|
+
send({ type: "SET_VALUE", value })
|
|
30
|
+
},
|
|
31
|
+
/**
|
|
32
|
+
* Clears the value of the tabs.
|
|
33
|
+
*/
|
|
34
|
+
clearValue() {
|
|
35
|
+
send({ type: "CLEAR_VALUE" })
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Sets the indicator rect to the tab with the given id.
|
|
39
|
+
*/
|
|
40
|
+
setIndicatorRect(id: string | null | undefined) {
|
|
41
|
+
send({ type: "SET_INDICATOR_RECT", id })
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
rootProps: normalize.element({
|
|
45
|
+
...parts.root.attrs,
|
|
46
|
+
id: dom.getRootId(state.context),
|
|
47
|
+
"data-orientation": state.context.orientation,
|
|
48
|
+
"data-focus": dataAttr(isFocused),
|
|
49
|
+
dir: state.context.dir,
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
tablistProps: normalize.element({
|
|
53
|
+
...parts.tablist.attrs,
|
|
54
|
+
id: dom.getTablistId(state.context),
|
|
55
|
+
role: "tablist",
|
|
56
|
+
"data-focus": dataAttr(isFocused),
|
|
57
|
+
"aria-orientation": state.context.orientation,
|
|
58
|
+
"data-orientation": state.context.orientation,
|
|
59
|
+
"aria-label": translations.tablistLabel,
|
|
60
|
+
onKeyDown(event) {
|
|
61
|
+
const keyMap: EventKeyMap = {
|
|
62
|
+
ArrowDown() {
|
|
63
|
+
send("ARROW_DOWN")
|
|
64
|
+
},
|
|
65
|
+
ArrowUp() {
|
|
66
|
+
send("ARROW_UP")
|
|
67
|
+
},
|
|
68
|
+
ArrowLeft() {
|
|
69
|
+
send("ARROW_LEFT")
|
|
70
|
+
},
|
|
71
|
+
ArrowRight() {
|
|
72
|
+
send("ARROW_RIGHT")
|
|
73
|
+
},
|
|
74
|
+
Home() {
|
|
75
|
+
send("HOME")
|
|
76
|
+
},
|
|
77
|
+
End() {
|
|
78
|
+
send("END")
|
|
79
|
+
},
|
|
80
|
+
Enter() {
|
|
81
|
+
send({ type: "ENTER", value: state.context.focusedValue })
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let key = getEventKey(event, state.context)
|
|
86
|
+
const exec = keyMap[key]
|
|
87
|
+
|
|
88
|
+
if (exec) {
|
|
89
|
+
event.preventDefault()
|
|
90
|
+
exec(event)
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
getTriggerProps(props: TriggerProps) {
|
|
96
|
+
const { value, disabled } = props
|
|
97
|
+
const selected = state.context.value === value
|
|
98
|
+
|
|
99
|
+
return normalize.button({
|
|
100
|
+
...parts.trigger.attrs,
|
|
101
|
+
role: "tab",
|
|
102
|
+
type: "button",
|
|
103
|
+
disabled,
|
|
104
|
+
"data-orientation": state.context.orientation,
|
|
105
|
+
"data-disabled": dataAttr(disabled),
|
|
106
|
+
"aria-disabled": disabled,
|
|
107
|
+
"data-value": value,
|
|
108
|
+
"aria-selected": selected,
|
|
109
|
+
"data-selected": dataAttr(selected),
|
|
110
|
+
"aria-controls": dom.getContentId(state.context, value),
|
|
111
|
+
"data-ownedby": dom.getTablistId(state.context),
|
|
112
|
+
id: dom.getTriggerId(state.context, value),
|
|
113
|
+
tabIndex: selected ? 0 : -1,
|
|
114
|
+
onFocus() {
|
|
115
|
+
send({ type: "TAB_FOCUS", value })
|
|
116
|
+
},
|
|
117
|
+
onBlur(event) {
|
|
118
|
+
const target = event.relatedTarget as HTMLElement | null
|
|
119
|
+
if (target?.getAttribute("role") !== "tab") {
|
|
120
|
+
send({ type: "TAB_BLUR" })
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
onClick(event) {
|
|
124
|
+
if (disabled) return
|
|
125
|
+
if (isSafari()) {
|
|
126
|
+
event.currentTarget.focus()
|
|
127
|
+
}
|
|
128
|
+
send({ type: "TAB_CLICK", value })
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
contentGroupProps: normalize.element({
|
|
134
|
+
...parts.contentGroup.attrs,
|
|
135
|
+
id: dom.getContentGroupId(state.context),
|
|
136
|
+
"data-orientation": state.context.orientation,
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
getContentProps({ value }: ContentProps) {
|
|
140
|
+
const selected = state.context.value === value
|
|
141
|
+
return normalize.element({
|
|
142
|
+
...parts.content.attrs,
|
|
143
|
+
id: dom.getContentId(state.context, value),
|
|
144
|
+
tabIndex: 0,
|
|
145
|
+
"aria-labelledby": dom.getTriggerId(state.context, value),
|
|
146
|
+
role: "tabpanel",
|
|
147
|
+
"data-ownedby": dom.getTablistId(state.context),
|
|
148
|
+
hidden: !selected,
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
indicatorProps: normalize.element({
|
|
153
|
+
id: dom.getIndicatorId(state.context),
|
|
154
|
+
...parts.indicator.attrs,
|
|
155
|
+
"data-orientation": state.context.orientation,
|
|
156
|
+
style: {
|
|
157
|
+
"--transition-duration": "150ms",
|
|
158
|
+
"--transition-property": "left, right, top, bottom, width, height",
|
|
159
|
+
position: "absolute",
|
|
160
|
+
willChange: "var(--transition-property)",
|
|
161
|
+
transitionProperty: "var(--transition-property)",
|
|
162
|
+
transitionDuration: state.context.canIndicatorTransition ? "var(--transition-duration)" : "0ms",
|
|
163
|
+
transitionTimingFunction: "var(--transition-timing-function)",
|
|
164
|
+
...state.context.indicatorRect,
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/tabs.dom.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createScope, itemById, nextById, prevById, queryAll } from "@zag-js/dom-query"
|
|
2
|
+
import { first, last } from "@zag-js/utils"
|
|
3
|
+
import type { MachineContext as Ctx } from "./tabs.types"
|
|
4
|
+
|
|
5
|
+
export const dom = createScope({
|
|
6
|
+
getRootId: (ctx: Ctx) => ctx.ids?.root ?? `tabs:${ctx.id}`,
|
|
7
|
+
getTablistId: (ctx: Ctx) => ctx.ids?.tablist ?? `tabs:${ctx.id}:list`,
|
|
8
|
+
getContentId: (ctx: Ctx, id: string) => ctx.ids?.content ?? `tabs:${ctx.id}:content-${id}`,
|
|
9
|
+
getContentGroupId: (ctx: Ctx) => ctx.ids?.contentGroup ?? `tabs:${ctx.id}:content-group`,
|
|
10
|
+
getTriggerId: (ctx: Ctx, id: string) => ctx.ids?.trigger ?? `tabs:${ctx.id}:trigger-${id}`,
|
|
11
|
+
getIndicatorId: (ctx: Ctx) => ctx.ids?.indicator ?? `tabs:${ctx.id}:indicator`,
|
|
12
|
+
|
|
13
|
+
getTablistEl: (ctx: Ctx) => dom.getById(ctx, dom.getTablistId(ctx)),
|
|
14
|
+
getContentEl: (ctx: Ctx, id: string) => dom.getById(ctx, dom.getContentId(ctx, id)),
|
|
15
|
+
getTriggerEl: (ctx: Ctx, id: string) => dom.getById(ctx, dom.getTriggerId(ctx, id)),
|
|
16
|
+
getIndicatorEl: (ctx: Ctx) => dom.getById(ctx, dom.getIndicatorId(ctx)),
|
|
17
|
+
|
|
18
|
+
getElements: (ctx: Ctx) => {
|
|
19
|
+
const ownerId = CSS.escape(dom.getTablistId(ctx))
|
|
20
|
+
const selector = `[role=tab][data-ownedby='${ownerId}']:not([disabled])`
|
|
21
|
+
return queryAll(dom.getTablistEl(ctx), selector)
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
getFirstEl: (ctx: Ctx) => first(dom.getElements(ctx)),
|
|
25
|
+
getLastEl: (ctx: Ctx) => last(dom.getElements(ctx)),
|
|
26
|
+
getNextEl: (ctx: Ctx, id: string) => nextById(dom.getElements(ctx), dom.getTriggerId(ctx, id), ctx.loop),
|
|
27
|
+
getPrevEl: (ctx: Ctx, id: string) => prevById(dom.getElements(ctx), dom.getTriggerId(ctx, id), ctx.loop),
|
|
28
|
+
getActiveContentEl: (ctx: Ctx) => {
|
|
29
|
+
if (!ctx.value) return
|
|
30
|
+
return dom.getContentEl(ctx, ctx.value)
|
|
31
|
+
},
|
|
32
|
+
getActiveTabEl: (ctx: Ctx) => {
|
|
33
|
+
if (!ctx.value) return
|
|
34
|
+
return dom.getTriggerEl(ctx, ctx.value)
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
getOffsetRect: (el: HTMLElement | undefined) => {
|
|
38
|
+
return {
|
|
39
|
+
left: el?.offsetLeft ?? 0,
|
|
40
|
+
top: el?.offsetTop ?? 0,
|
|
41
|
+
width: el?.offsetWidth ?? 0,
|
|
42
|
+
height: el?.offsetHeight ?? 0,
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
getRectById: (ctx: Ctx, id: string) => {
|
|
47
|
+
const tab = itemById(dom.getElements(ctx), dom.getTriggerId(ctx, id))
|
|
48
|
+
return dom.resolveRect(dom.getOffsetRect(tab), ctx.orientation)
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
resolveRect(rect: Record<"width" | "height" | "left" | "top", number>, orientation?: "horizontal" | "vertical") {
|
|
52
|
+
const sizeProp = orientation === "vertical" ? "height" : "width"
|
|
53
|
+
const placementProp = orientation === "vertical" ? "top" : "left"
|
|
54
|
+
return {
|
|
55
|
+
[placementProp]: `${rect[placementProp]}px`,
|
|
56
|
+
[sizeProp]: `${rect[sizeProp]}px`,
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createMachine, guards } from "@zag-js/core"
|
|
2
|
+
import { nextTick, raf } from "@zag-js/dom-query"
|
|
3
|
+
import { trackElementRect } from "@zag-js/element-rect"
|
|
4
|
+
import { getFocusables } from "@zag-js/tabbable"
|
|
5
|
+
import { compact } from "@zag-js/utils"
|
|
6
|
+
import { dom } from "./tabs.dom"
|
|
7
|
+
import type { MachineContext, MachineState, UserDefinedContext } from "./tabs.types"
|
|
8
|
+
|
|
9
|
+
const { not } = guards
|
|
10
|
+
|
|
11
|
+
export function machine(userContext: UserDefinedContext) {
|
|
12
|
+
const ctx = compact(userContext)
|
|
13
|
+
return createMachine<MachineContext, MachineState>(
|
|
14
|
+
{
|
|
15
|
+
initial: "idle",
|
|
16
|
+
|
|
17
|
+
context: {
|
|
18
|
+
dir: "ltr",
|
|
19
|
+
orientation: "horizontal",
|
|
20
|
+
activationMode: "automatic",
|
|
21
|
+
value: null,
|
|
22
|
+
focusedValue: null,
|
|
23
|
+
previousValues: [],
|
|
24
|
+
indicatorRect: {
|
|
25
|
+
left: "0px",
|
|
26
|
+
top: "0px",
|
|
27
|
+
width: "0px",
|
|
28
|
+
height: "0px",
|
|
29
|
+
},
|
|
30
|
+
canIndicatorTransition: false,
|
|
31
|
+
isIndicatorRendered: false,
|
|
32
|
+
loop: true,
|
|
33
|
+
translations: {},
|
|
34
|
+
...ctx,
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
computed: {
|
|
38
|
+
isHorizontal: (ctx) => ctx.orientation === "horizontal",
|
|
39
|
+
isVertical: (ctx) => ctx.orientation === "vertical",
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
created: ["setPrevSelectedTabs"],
|
|
43
|
+
|
|
44
|
+
entry: ["checkRenderedElements", "syncIndicatorRect", "setContentTabIndex"],
|
|
45
|
+
|
|
46
|
+
exit: ["cleanupObserver"],
|
|
47
|
+
|
|
48
|
+
watch: {
|
|
49
|
+
focusedValue: "invokeOnFocus",
|
|
50
|
+
value: [
|
|
51
|
+
"enableIndicatorTransition",
|
|
52
|
+
"invokeOnChange",
|
|
53
|
+
"setPrevSelectedTabs",
|
|
54
|
+
"syncIndicatorRect",
|
|
55
|
+
"setContentTabIndex",
|
|
56
|
+
],
|
|
57
|
+
dir: ["syncIndicatorRect"],
|
|
58
|
+
orientation: ["syncIndicatorRect"],
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
on: {
|
|
62
|
+
SET_VALUE: {
|
|
63
|
+
actions: "setValue",
|
|
64
|
+
},
|
|
65
|
+
CLEAR_VALUE: {
|
|
66
|
+
actions: "clearValue",
|
|
67
|
+
},
|
|
68
|
+
SET_INDICATOR_RECT: {
|
|
69
|
+
actions: "setIndicatorRect",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
states: {
|
|
74
|
+
idle: {
|
|
75
|
+
on: {
|
|
76
|
+
TAB_FOCUS: [
|
|
77
|
+
{
|
|
78
|
+
guard: "selectOnFocus",
|
|
79
|
+
target: "focused",
|
|
80
|
+
actions: ["setFocusedValue", "setValue"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
target: "focused",
|
|
84
|
+
actions: "setFocusedValue",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
TAB_CLICK: {
|
|
88
|
+
target: "focused",
|
|
89
|
+
actions: ["setFocusedValue", "setValue"],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
focused: {
|
|
94
|
+
on: {
|
|
95
|
+
TAB_CLICK: {
|
|
96
|
+
target: "focused",
|
|
97
|
+
actions: ["setFocusedValue", "setValue"],
|
|
98
|
+
},
|
|
99
|
+
ARROW_LEFT: {
|
|
100
|
+
guard: "isHorizontal",
|
|
101
|
+
actions: "focusPrevTab",
|
|
102
|
+
},
|
|
103
|
+
ARROW_RIGHT: {
|
|
104
|
+
guard: "isHorizontal",
|
|
105
|
+
actions: "focusNextTab",
|
|
106
|
+
},
|
|
107
|
+
ARROW_UP: {
|
|
108
|
+
guard: "isVertical",
|
|
109
|
+
actions: "focusPrevTab",
|
|
110
|
+
},
|
|
111
|
+
ARROW_DOWN: {
|
|
112
|
+
guard: "isVertical",
|
|
113
|
+
actions: "focusNextTab",
|
|
114
|
+
},
|
|
115
|
+
HOME: {
|
|
116
|
+
actions: "focusFirstTab",
|
|
117
|
+
},
|
|
118
|
+
END: {
|
|
119
|
+
actions: "focusLastTab",
|
|
120
|
+
},
|
|
121
|
+
ENTER: {
|
|
122
|
+
guard: not("selectOnFocus"),
|
|
123
|
+
actions: "setValue",
|
|
124
|
+
},
|
|
125
|
+
TAB_FOCUS: [
|
|
126
|
+
{
|
|
127
|
+
guard: "selectOnFocus",
|
|
128
|
+
actions: ["setFocusedValue", "setValue"],
|
|
129
|
+
},
|
|
130
|
+
{ actions: "setFocusedValue" },
|
|
131
|
+
],
|
|
132
|
+
TAB_BLUR: {
|
|
133
|
+
target: "idle",
|
|
134
|
+
actions: "clearFocusedValue",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
guards: {
|
|
142
|
+
isVertical: (ctx) => ctx.isVertical,
|
|
143
|
+
isHorizontal: (ctx) => ctx.isHorizontal,
|
|
144
|
+
selectOnFocus: (ctx) => ctx.activationMode === "automatic",
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
actions: {
|
|
148
|
+
setFocusedValue(ctx, evt) {
|
|
149
|
+
ctx.focusedValue = evt.value
|
|
150
|
+
},
|
|
151
|
+
clearFocusedValue(ctx) {
|
|
152
|
+
ctx.focusedValue = null
|
|
153
|
+
},
|
|
154
|
+
setValue(ctx, evt) {
|
|
155
|
+
ctx.value = evt.value
|
|
156
|
+
},
|
|
157
|
+
clearValue(ctx) {
|
|
158
|
+
ctx.value = null
|
|
159
|
+
},
|
|
160
|
+
focusFirstTab(ctx) {
|
|
161
|
+
raf(() => dom.getFirstEl(ctx)?.focus())
|
|
162
|
+
},
|
|
163
|
+
focusLastTab(ctx) {
|
|
164
|
+
raf(() => dom.getLastEl(ctx)?.focus())
|
|
165
|
+
},
|
|
166
|
+
focusNextTab(ctx) {
|
|
167
|
+
if (!ctx.focusedValue) return
|
|
168
|
+
const next = dom.getNextEl(ctx, ctx.focusedValue)
|
|
169
|
+
raf(() => next?.focus())
|
|
170
|
+
},
|
|
171
|
+
focusPrevTab(ctx) {
|
|
172
|
+
if (!ctx.focusedValue) return
|
|
173
|
+
const prev = dom.getPrevEl(ctx, ctx.focusedValue)
|
|
174
|
+
raf(() => prev?.focus())
|
|
175
|
+
},
|
|
176
|
+
checkRenderedElements(ctx) {
|
|
177
|
+
ctx.isIndicatorRendered = !!dom.getIndicatorEl(ctx)
|
|
178
|
+
},
|
|
179
|
+
invokeOnChange(ctx) {
|
|
180
|
+
ctx.onChange?.({ value: ctx.value })
|
|
181
|
+
},
|
|
182
|
+
invokeOnFocus(ctx) {
|
|
183
|
+
ctx.onFocus?.({ value: ctx.focusedValue })
|
|
184
|
+
},
|
|
185
|
+
setPrevSelectedTabs(ctx) {
|
|
186
|
+
if (ctx.value != null) {
|
|
187
|
+
ctx.previousValues = pushUnique(ctx.previousValues, ctx.value)
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
// if tab panel contains focusable elements, remove the tabindex attribute
|
|
191
|
+
setContentTabIndex(ctx) {
|
|
192
|
+
raf(() => {
|
|
193
|
+
const panel = dom.getActiveContentEl(ctx)
|
|
194
|
+
if (!panel) return
|
|
195
|
+
const focusables = getFocusables(panel)
|
|
196
|
+
if (focusables.length > 0) {
|
|
197
|
+
panel.removeAttribute("tabindex")
|
|
198
|
+
} else {
|
|
199
|
+
panel.setAttribute("tabindex", "0")
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
},
|
|
203
|
+
cleanupObserver(ctx) {
|
|
204
|
+
ctx.indicatorCleanup?.()
|
|
205
|
+
},
|
|
206
|
+
enableIndicatorTransition(ctx) {
|
|
207
|
+
ctx.canIndicatorTransition = true
|
|
208
|
+
},
|
|
209
|
+
setIndicatorRect(ctx, evt) {
|
|
210
|
+
const value = evt.id ?? ctx.value
|
|
211
|
+
if (!ctx.isIndicatorRendered || !value) return
|
|
212
|
+
|
|
213
|
+
const tabEl = dom.getTriggerEl(ctx, value)
|
|
214
|
+
if (!tabEl) return
|
|
215
|
+
|
|
216
|
+
ctx.indicatorRect = dom.getRectById(ctx, value)
|
|
217
|
+
nextTick(() => {
|
|
218
|
+
ctx.canIndicatorTransition = false
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
syncIndicatorRect(ctx) {
|
|
222
|
+
ctx.indicatorCleanup?.()
|
|
223
|
+
|
|
224
|
+
const value = ctx.value
|
|
225
|
+
if (!ctx.isIndicatorRendered || !value) return
|
|
226
|
+
|
|
227
|
+
const tabEl = dom.getActiveTabEl(ctx)
|
|
228
|
+
if (!tabEl) return
|
|
229
|
+
|
|
230
|
+
ctx.indicatorCleanup = trackElementRect(tabEl, {
|
|
231
|
+
getRect(el) {
|
|
232
|
+
return dom.getOffsetRect(el)
|
|
233
|
+
},
|
|
234
|
+
onChange(rect) {
|
|
235
|
+
ctx.indicatorRect = dom.resolveRect(rect, ctx.orientation)
|
|
236
|
+
nextTick(() => {
|
|
237
|
+
ctx.canIndicatorTransition = false
|
|
238
|
+
})
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// function to push value array and remove previous instances of value
|
|
248
|
+
function pushUnique(arr: string[], value: any) {
|
|
249
|
+
const newArr = Array.from(arr).slice()
|
|
250
|
+
const index = newArr.indexOf(value)
|
|
251
|
+
if (index > -1) {
|
|
252
|
+
newArr.splice(index, 1)
|
|
253
|
+
}
|
|
254
|
+
newArr.push(value)
|
|
255
|
+
return newArr
|
|
256
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { StateMachine as S } from "@zag-js/core"
|
|
2
|
+
import type { CommonProperties, Context, DirectionProperty, RequiredBy } from "@zag-js/types"
|
|
3
|
+
|
|
4
|
+
type IntlTranslations = {
|
|
5
|
+
tablistLabel?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type ElementIds = Partial<{
|
|
9
|
+
root: string
|
|
10
|
+
trigger: string
|
|
11
|
+
tablist: string
|
|
12
|
+
contentGroup: string
|
|
13
|
+
content: string
|
|
14
|
+
indicator: string
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export type TriggerProps = {
|
|
18
|
+
value: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ContentProps = {
|
|
23
|
+
value: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PublicContext = DirectionProperty &
|
|
27
|
+
CommonProperties & {
|
|
28
|
+
/**
|
|
29
|
+
* The ids of the elements in the tabs. Useful for composition.
|
|
30
|
+
*/
|
|
31
|
+
ids?: ElementIds
|
|
32
|
+
/**
|
|
33
|
+
* Specifies the localized strings that identifies the accessibility elements and their states
|
|
34
|
+
*/
|
|
35
|
+
translations: IntlTranslations
|
|
36
|
+
/**
|
|
37
|
+
* Whether the keyboard navigation will loop from last tab to first, and vice versa.
|
|
38
|
+
* @default true
|
|
39
|
+
*/
|
|
40
|
+
loop: boolean
|
|
41
|
+
/**
|
|
42
|
+
* The selected tab id
|
|
43
|
+
*/
|
|
44
|
+
value: string | null
|
|
45
|
+
/**
|
|
46
|
+
* The orientation of the tabs. Can be `horizontal` or `vertical`
|
|
47
|
+
* - `horizontal`: only left and right arrow key navigation will work.
|
|
48
|
+
* - `vertical`: only up and down arrow key navigation will work.
|
|
49
|
+
*
|
|
50
|
+
* @default "horizontal"
|
|
51
|
+
*/
|
|
52
|
+
orientation?: "horizontal" | "vertical"
|
|
53
|
+
/**
|
|
54
|
+
* The activation mode of the tabs. Can be `manual` or `automatic`
|
|
55
|
+
* - `manual`: Tabs are activated when clicked or press `enter` key.
|
|
56
|
+
* - `automatic`: Tabs are activated when receiving focus
|
|
57
|
+
* @default "automatic"
|
|
58
|
+
*/
|
|
59
|
+
activationMode?: "manual" | "automatic"
|
|
60
|
+
/**
|
|
61
|
+
* Callback to be called when the selected/active tab changes
|
|
62
|
+
*/
|
|
63
|
+
onChange?: (details: { value: string | null }) => void
|
|
64
|
+
/**
|
|
65
|
+
* Callback to be called when the focused tab changes
|
|
66
|
+
*/
|
|
67
|
+
onFocus?: (details: { value: string | null }) => void
|
|
68
|
+
/**
|
|
69
|
+
* Callback to be called when a tab's close button is clicked
|
|
70
|
+
*/
|
|
71
|
+
onDelete?: (details: { value: string }) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type UserDefinedContext = RequiredBy<PublicContext, "id">
|
|
75
|
+
|
|
76
|
+
type ComputedContext = Readonly<{
|
|
77
|
+
/**
|
|
78
|
+
* @computed
|
|
79
|
+
* Whether the tab is in the horizontal orientation
|
|
80
|
+
*/
|
|
81
|
+
isHorizontal: boolean
|
|
82
|
+
/**
|
|
83
|
+
* @computed
|
|
84
|
+
* Whether the tab is in the vertical orientation
|
|
85
|
+
*/
|
|
86
|
+
isVertical: boolean
|
|
87
|
+
}>
|
|
88
|
+
|
|
89
|
+
type PrivateContext = Context<{
|
|
90
|
+
/**
|
|
91
|
+
* @internal
|
|
92
|
+
* The focused tab id
|
|
93
|
+
*/
|
|
94
|
+
focusedValue: string | null
|
|
95
|
+
/**
|
|
96
|
+
* @internal
|
|
97
|
+
* Whether the indicator is rendered.
|
|
98
|
+
*/
|
|
99
|
+
isIndicatorRendered: boolean
|
|
100
|
+
/**
|
|
101
|
+
* @internal
|
|
102
|
+
* The active tab indicator's dom rect
|
|
103
|
+
*/
|
|
104
|
+
indicatorRect?: Partial<{ left: string; top: string; width: string; height: string }>
|
|
105
|
+
/**
|
|
106
|
+
* @internal
|
|
107
|
+
* Whether the active tab indicator's rect can transition
|
|
108
|
+
*/
|
|
109
|
+
canIndicatorTransition?: boolean
|
|
110
|
+
/**
|
|
111
|
+
* @internal
|
|
112
|
+
* The previously selected tab ids. This is useful for performance optimization
|
|
113
|
+
*/
|
|
114
|
+
previousValues: string[]
|
|
115
|
+
/**
|
|
116
|
+
* @internal
|
|
117
|
+
* Function to clean up the observer for the active tab's rect
|
|
118
|
+
*/
|
|
119
|
+
indicatorCleanup?: VoidFunction | null
|
|
120
|
+
}>
|
|
121
|
+
|
|
122
|
+
export type MachineContext = PublicContext & ComputedContext & PrivateContext
|
|
123
|
+
|
|
124
|
+
export type MachineState = {
|
|
125
|
+
value: "idle" | "focused"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type State = S.State<MachineContext, MachineState>
|
|
129
|
+
|
|
130
|
+
export type Send = S.Send<S.AnyEventObject>
|