@octanejs/radix 0.1.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- package/src/useId.ts +15 -0
package/src/Slot.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-slot (the MODERN version). `Slot` merges its own props onto
|
|
2
|
+
// a single element child and renders that child in place — the engine behind `asChild`.
|
|
3
|
+
// React's `forwardRef` + `Children`/`cloneElement` map onto octane's ref-as-prop + the
|
|
4
|
+
// runtime's `Children`/`cloneElement`/`isValidElement`.
|
|
5
|
+
//
|
|
6
|
+
// CRITICAL (learned the hard way, mirroring Radix's own history): the composed ref MUST be
|
|
7
|
+
// memoized (`useComposedRefs`), not built fresh per render — a fresh identity makes the
|
|
8
|
+
// renderer detach(null)+re-attach the child's ref every render, and refs that are state
|
|
9
|
+
// setters (DismissableLayer/FocusScope/Presence `setNode`) then re-render → fresh ref →
|
|
10
|
+
// an infinite loop. Radix's legacy inline `composeRefs` slot had this exact churn.
|
|
11
|
+
//
|
|
12
|
+
// IMPORTANT (octane): these operate on element DESCRIPTORS. In `.tsrx`, prop-position JSX
|
|
13
|
+
// (`el={<button/>}`), `createElement`, and `.map()` returns are descriptors, but
|
|
14
|
+
// children-position JSX compiles to a render function — so `asChild` consumers pass the
|
|
15
|
+
// child element through a prop / value position (see docs/radix-migration-plan.md).
|
|
16
|
+
import { Children, cloneElement, isValidElement, normalizeClass } from 'octane';
|
|
17
|
+
|
|
18
|
+
import { useComposedRefs } from './compose-refs';
|
|
19
|
+
import { S, subSlot } from './internal';
|
|
20
|
+
|
|
21
|
+
/** A marker whose child element `Slot` projects while keeping its sibling children. */
|
|
22
|
+
export function Slottable(props: { children?: any }): any {
|
|
23
|
+
return props.children;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isSlottable(child: any): boolean {
|
|
27
|
+
return isValidElement(child) && child.type === Slottable;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// slotProps (the behavior) merged UNDER childProps (the user's element wins), except:
|
|
31
|
+
// event handlers chain (child first, then behavior), `style` merges (child wins per-key),
|
|
32
|
+
// and `class`/`className` compose clsx-style via octane's normalizeClass.
|
|
33
|
+
function mergeProps(slotProps: any, childProps: any): any {
|
|
34
|
+
const overrideProps: any = { ...childProps };
|
|
35
|
+
for (const propName in childProps) {
|
|
36
|
+
const slotPropValue = slotProps[propName];
|
|
37
|
+
const childPropValue = childProps[propName];
|
|
38
|
+
const isHandler = /^on[A-Z]/.test(propName);
|
|
39
|
+
if (isHandler) {
|
|
40
|
+
if (slotPropValue && childPropValue) {
|
|
41
|
+
overrideProps[propName] = (...args: any[]) => {
|
|
42
|
+
const result = childPropValue(...args);
|
|
43
|
+
slotPropValue(...args);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
} else if (slotPropValue) {
|
|
47
|
+
overrideProps[propName] = slotPropValue;
|
|
48
|
+
}
|
|
49
|
+
} else if (propName === 'style') {
|
|
50
|
+
overrideProps[propName] = { ...slotPropValue, ...childPropValue };
|
|
51
|
+
} else if (propName === 'className' || propName === 'class') {
|
|
52
|
+
overrideProps[propName] = normalizeClass([slotPropValue, childPropValue]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ...slotProps, ...overrideProps };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function Slot(props: any): any {
|
|
59
|
+
const slot = S('Slot');
|
|
60
|
+
const { children, ...slotProps } = props ?? {};
|
|
61
|
+
|
|
62
|
+
// Resolve the element to project onto. With a `<Slottable>` marker among the children,
|
|
63
|
+
// its child is the projection target and the siblings become the new children.
|
|
64
|
+
const childrenArray = Children.toArray(children);
|
|
65
|
+
const slottable = childrenArray.find(isSlottable);
|
|
66
|
+
let targetChild: any = children;
|
|
67
|
+
// octane convention: children are often passed as an ARRAY prop (that's how a .tsrx
|
|
68
|
+
// caller provides enumerable children — see Dialog.Portal). React's Children.only
|
|
69
|
+
// rejects arrays outright; here a single-element array unwraps to its element.
|
|
70
|
+
if (!slottable && Array.isArray(children) && childrenArray.length === 1) {
|
|
71
|
+
targetChild = childrenArray[0];
|
|
72
|
+
}
|
|
73
|
+
let newChildren: any = null;
|
|
74
|
+
let hasSlottable = false;
|
|
75
|
+
if (slottable) {
|
|
76
|
+
hasSlottable = true;
|
|
77
|
+
const newElement = (slottable as any).props.children;
|
|
78
|
+
newChildren = Children.map(children, (child: any) => {
|
|
79
|
+
if (child === slottable) {
|
|
80
|
+
if (Children.count(newElement) > 1) return Children.only(null as any);
|
|
81
|
+
return isValidElement(newElement) ? (newElement as any).props.children : null;
|
|
82
|
+
}
|
|
83
|
+
return child;
|
|
84
|
+
});
|
|
85
|
+
targetChild = isValidElement(newElement)
|
|
86
|
+
? cloneElement(newElement as any, undefined, newChildren)
|
|
87
|
+
: null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const childIsElement = isValidElement(targetChild);
|
|
91
|
+
const childRef = childIsElement ? (targetChild as any).props?.ref : undefined;
|
|
92
|
+
const slotRef = slotProps.ref;
|
|
93
|
+
// MEMOIZED composed ref — see the header note. (octane allows conditional hooks, but
|
|
94
|
+
// this one runs unconditionally anyway.)
|
|
95
|
+
const composedRef = useComposedRefs(slotRef, childRef, subSlot(slot, 'refs'));
|
|
96
|
+
|
|
97
|
+
if (childIsElement) {
|
|
98
|
+
const merged = mergeProps(slotProps, (targetChild as any).props ?? {});
|
|
99
|
+
merged.ref = slotRef !== undefined ? composedRef : childRef;
|
|
100
|
+
if (hasSlottable) merged.children = newChildren;
|
|
101
|
+
return cloneElement(targetChild as any, merged);
|
|
102
|
+
}
|
|
103
|
+
// 0 children → nothing; >1 → Children.only throws (like Radix).
|
|
104
|
+
return Children.count(targetChild) > 1 ? Children.only(targetChild) : null;
|
|
105
|
+
}
|
package/src/Switch.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-switch (source:
|
|
2
|
+
// .radix-primitives/packages/react/switch/src/switch.tsx). A `role=switch` button with
|
|
3
|
+
// a Thumb; inside a form it renders the hidden native-checkbox "bubble input" (same
|
|
4
|
+
// machinery as Checkbox — uncontrolled input, imperative `checked` setter + dispatched
|
|
5
|
+
// events). Same octane adaptations as Checkbox.ts: `isPropagationStopped()` →
|
|
6
|
+
// `event.cancelBubble`; `defaultChecked` → the native `checked` attribute; a native
|
|
7
|
+
// `change` dispatched alongside the source's `click`.
|
|
8
|
+
import { createElement, useEffect, useRef, useState } from 'octane';
|
|
9
|
+
|
|
10
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
11
|
+
import { useComposedRefs } from './compose-refs';
|
|
12
|
+
import { createContextScope } from './context';
|
|
13
|
+
import { S, subSlot } from './internal';
|
|
14
|
+
import { Primitive } from './Primitive';
|
|
15
|
+
import { usePrevious } from './use-previous';
|
|
16
|
+
import { useSize } from './use-size';
|
|
17
|
+
import { useControllableState } from './useControllableState';
|
|
18
|
+
|
|
19
|
+
const SWITCH_NAME = 'Switch';
|
|
20
|
+
|
|
21
|
+
const [createSwitchContext, createSwitchScope] = createContextScope(SWITCH_NAME);
|
|
22
|
+
export { createSwitchScope };
|
|
23
|
+
|
|
24
|
+
interface SwitchContextValue {
|
|
25
|
+
checked: boolean;
|
|
26
|
+
setChecked: (checked: boolean | ((prev: boolean) => boolean)) => void;
|
|
27
|
+
disabled: boolean | undefined;
|
|
28
|
+
control: HTMLElement | null;
|
|
29
|
+
setControl: (control: HTMLElement | null) => void;
|
|
30
|
+
name: string | undefined;
|
|
31
|
+
form: string | undefined;
|
|
32
|
+
value: string | number;
|
|
33
|
+
hasConsumerStoppedPropagationRef: { current: boolean };
|
|
34
|
+
required: boolean | undefined;
|
|
35
|
+
defaultChecked: boolean | undefined;
|
|
36
|
+
isFormControl: boolean;
|
|
37
|
+
bubbleInput: HTMLInputElement | null;
|
|
38
|
+
setBubbleInput: (input: HTMLInputElement | null) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [SwitchProviderImpl, useSwitchContext] = createSwitchContext<SwitchContextValue>(SWITCH_NAME);
|
|
42
|
+
|
|
43
|
+
export function Provider(props: any): any {
|
|
44
|
+
const slot = S('Switch.Provider');
|
|
45
|
+
const {
|
|
46
|
+
__scopeSwitch,
|
|
47
|
+
checked: checkedProp,
|
|
48
|
+
children,
|
|
49
|
+
defaultChecked,
|
|
50
|
+
disabled,
|
|
51
|
+
form,
|
|
52
|
+
name,
|
|
53
|
+
onCheckedChange,
|
|
54
|
+
required,
|
|
55
|
+
value = 'on',
|
|
56
|
+
internal_do_not_use_render,
|
|
57
|
+
} = props ?? {};
|
|
58
|
+
|
|
59
|
+
const [checked, setChecked] = useControllableState<boolean>(
|
|
60
|
+
{ prop: checkedProp, defaultProp: defaultChecked ?? false, onChange: onCheckedChange },
|
|
61
|
+
subSlot(slot, 'checked'),
|
|
62
|
+
);
|
|
63
|
+
const [control, setControl] = useState<HTMLElement | null>(null, subSlot(slot, 'control'));
|
|
64
|
+
const [bubbleInput, setBubbleInput] = useState<HTMLInputElement | null>(
|
|
65
|
+
null,
|
|
66
|
+
subSlot(slot, 'input'),
|
|
67
|
+
);
|
|
68
|
+
const hasConsumerStoppedPropagationRef = useRef(false, subSlot(slot, 'stopped'));
|
|
69
|
+
const isFormControl = control
|
|
70
|
+
? !!form || !!control.closest('form')
|
|
71
|
+
: // We set this to true by default so that events bubble to forms without JS (SSR)
|
|
72
|
+
true;
|
|
73
|
+
|
|
74
|
+
const context: SwitchContextValue = {
|
|
75
|
+
checked,
|
|
76
|
+
setChecked,
|
|
77
|
+
disabled,
|
|
78
|
+
control,
|
|
79
|
+
setControl,
|
|
80
|
+
name,
|
|
81
|
+
form,
|
|
82
|
+
value,
|
|
83
|
+
hasConsumerStoppedPropagationRef,
|
|
84
|
+
required,
|
|
85
|
+
defaultChecked,
|
|
86
|
+
isFormControl,
|
|
87
|
+
bubbleInput,
|
|
88
|
+
setBubbleInput,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return createElement(SwitchProviderImpl, {
|
|
92
|
+
scope: __scopeSwitch,
|
|
93
|
+
...context,
|
|
94
|
+
children: isFunction(internal_do_not_use_render)
|
|
95
|
+
? internal_do_not_use_render(context)
|
|
96
|
+
: children,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function Trigger(props: any): any {
|
|
101
|
+
const slot = S('Switch.Trigger');
|
|
102
|
+
const { __scopeSwitch, onClick, ref: forwardedRef, ...switchProps } = props ?? {};
|
|
103
|
+
const {
|
|
104
|
+
value,
|
|
105
|
+
disabled,
|
|
106
|
+
checked,
|
|
107
|
+
required,
|
|
108
|
+
setControl,
|
|
109
|
+
setChecked,
|
|
110
|
+
hasConsumerStoppedPropagationRef,
|
|
111
|
+
isFormControl,
|
|
112
|
+
bubbleInput,
|
|
113
|
+
} = useSwitchContext('SwitchTrigger', __scopeSwitch);
|
|
114
|
+
const composedRefs = useComposedRefs(forwardedRef, setControl, subSlot(slot, 'refs'));
|
|
115
|
+
|
|
116
|
+
return createElement(Primitive.button, {
|
|
117
|
+
type: 'button',
|
|
118
|
+
role: 'switch',
|
|
119
|
+
'aria-checked': checked,
|
|
120
|
+
'aria-required': required,
|
|
121
|
+
'data-state': getState(checked),
|
|
122
|
+
'data-disabled': disabled ? '' : undefined,
|
|
123
|
+
disabled,
|
|
124
|
+
value,
|
|
125
|
+
...switchProps,
|
|
126
|
+
ref: composedRefs,
|
|
127
|
+
onClick: composeEventHandlers(onClick, (event: MouseEvent) => {
|
|
128
|
+
setChecked((prevChecked) => !prevChecked);
|
|
129
|
+
if (bubbleInput && isFormControl) {
|
|
130
|
+
hasConsumerStoppedPropagationRef.current = event.cancelBubble;
|
|
131
|
+
// if switch has a bubble input and is a form control, stop
|
|
132
|
+
// propagation from the button so that we only propagate one click
|
|
133
|
+
// event (from the input). We propagate changes from an input so
|
|
134
|
+
// that native form validation works and form events reflect switch
|
|
135
|
+
// updates.
|
|
136
|
+
if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation();
|
|
137
|
+
}
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function Root(props: any): any {
|
|
143
|
+
const {
|
|
144
|
+
__scopeSwitch,
|
|
145
|
+
name,
|
|
146
|
+
checked,
|
|
147
|
+
defaultChecked,
|
|
148
|
+
required,
|
|
149
|
+
disabled,
|
|
150
|
+
value,
|
|
151
|
+
onCheckedChange,
|
|
152
|
+
form,
|
|
153
|
+
ref: forwardedRef,
|
|
154
|
+
...switchProps
|
|
155
|
+
} = props ?? {};
|
|
156
|
+
|
|
157
|
+
return createElement(Provider, {
|
|
158
|
+
__scopeSwitch,
|
|
159
|
+
checked,
|
|
160
|
+
defaultChecked,
|
|
161
|
+
disabled,
|
|
162
|
+
required,
|
|
163
|
+
onCheckedChange,
|
|
164
|
+
name,
|
|
165
|
+
form,
|
|
166
|
+
value,
|
|
167
|
+
internal_do_not_use_render: ({ isFormControl }: SwitchContextValue) => [
|
|
168
|
+
createElement(Trigger, {
|
|
169
|
+
key: 'trigger',
|
|
170
|
+
...switchProps,
|
|
171
|
+
ref: forwardedRef,
|
|
172
|
+
__scopeSwitch,
|
|
173
|
+
}),
|
|
174
|
+
isFormControl ? createElement(BubbleInput, { key: 'bubble', __scopeSwitch }) : null,
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function Thumb(props: any): any {
|
|
180
|
+
const { __scopeSwitch, ...thumbProps } = props ?? {};
|
|
181
|
+
const context = useSwitchContext('SwitchThumb', __scopeSwitch);
|
|
182
|
+
return createElement(Primitive.span, {
|
|
183
|
+
'data-state': getState(context.checked),
|
|
184
|
+
'data-disabled': context.disabled ? '' : undefined,
|
|
185
|
+
...thumbProps,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function BubbleInput(props: any): any {
|
|
190
|
+
const slot = S('Switch.BubbleInput');
|
|
191
|
+
const { __scopeSwitch, ref: forwardedRef, ...inputProps } = props ?? {};
|
|
192
|
+
const {
|
|
193
|
+
control,
|
|
194
|
+
hasConsumerStoppedPropagationRef,
|
|
195
|
+
checked,
|
|
196
|
+
defaultChecked,
|
|
197
|
+
required,
|
|
198
|
+
disabled,
|
|
199
|
+
name,
|
|
200
|
+
value,
|
|
201
|
+
form,
|
|
202
|
+
bubbleInput,
|
|
203
|
+
setBubbleInput,
|
|
204
|
+
} = useSwitchContext('SwitchBubbleInput', __scopeSwitch);
|
|
205
|
+
|
|
206
|
+
const composedRefs = useComposedRefs(forwardedRef, setBubbleInput, subSlot(slot, 'refs'));
|
|
207
|
+
const prevChecked = usePrevious(checked, subSlot(slot, 'prev'));
|
|
208
|
+
const controlSize = useSize(control, subSlot(slot, 'size'));
|
|
209
|
+
|
|
210
|
+
// Bubble checked change to parents (e.g form change event)
|
|
211
|
+
useEffect(
|
|
212
|
+
() => {
|
|
213
|
+
const input = bubbleInput;
|
|
214
|
+
if (!input) return;
|
|
215
|
+
|
|
216
|
+
const inputProto = window.HTMLInputElement.prototype;
|
|
217
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
218
|
+
inputProto,
|
|
219
|
+
'checked',
|
|
220
|
+
) as PropertyDescriptor;
|
|
221
|
+
const setChecked = descriptor.set;
|
|
222
|
+
|
|
223
|
+
const bubbles = !hasConsumerStoppedPropagationRef.current;
|
|
224
|
+
if (prevChecked !== checked && setChecked) {
|
|
225
|
+
setChecked.call(input, checked);
|
|
226
|
+
input.dispatchEvent(new Event('click', { bubbles }));
|
|
227
|
+
// octane adaptation: also fire the native `change` (see Checkbox.ts header).
|
|
228
|
+
input.dispatchEvent(new Event('change', { bubbles }));
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
[bubbleInput, prevChecked, checked, hasConsumerStoppedPropagationRef],
|
|
232
|
+
subSlot(slot, 'e:bubble'),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const defaultCheckedRef = useRef(checked, subSlot(slot, 'default'));
|
|
236
|
+
return createElement(Primitive.input, {
|
|
237
|
+
type: 'checkbox',
|
|
238
|
+
'aria-hidden': true,
|
|
239
|
+
// octane: native `checked` attribute = default-checked state (see Checkbox.ts).
|
|
240
|
+
checked: (defaultChecked ?? defaultCheckedRef.current) || undefined,
|
|
241
|
+
required,
|
|
242
|
+
disabled,
|
|
243
|
+
name,
|
|
244
|
+
value,
|
|
245
|
+
form,
|
|
246
|
+
...inputProps,
|
|
247
|
+
tabIndex: -1,
|
|
248
|
+
ref: composedRefs,
|
|
249
|
+
style: {
|
|
250
|
+
...props?.style,
|
|
251
|
+
...controlSize,
|
|
252
|
+
position: 'absolute',
|
|
253
|
+
pointerEvents: 'none',
|
|
254
|
+
opacity: 0,
|
|
255
|
+
margin: 0,
|
|
256
|
+
// We transform because the input is absolutely positioned but we have
|
|
257
|
+
// rendered it **after** the button. This pulls it back to sit on top
|
|
258
|
+
// of the button.
|
|
259
|
+
transform: 'translateX(-100%)',
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isFunction(value: unknown): value is (...args: any[]) => any {
|
|
265
|
+
return typeof value === 'function';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getState(checked: boolean): string {
|
|
269
|
+
return checked ? 'checked' : 'unchecked';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
Root as Switch,
|
|
274
|
+
Provider as SwitchProvider,
|
|
275
|
+
Trigger as SwitchTrigger,
|
|
276
|
+
Thumb as SwitchThumb,
|
|
277
|
+
BubbleInput as SwitchBubbleInput,
|
|
278
|
+
};
|
package/src/Tabs.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-tabs. A set of layered content panels: List is a
|
|
2
|
+
// RovingFocusGroup of Triggers (`role=tab`, automatic-or-manual activation), Content
|
|
3
|
+
// panels mount through `Presence` (`role=tabpanel`), and trigger/content ids derive from
|
|
4
|
+
// one `baseId` so `aria-controls`/`aria-labelledby` wire up.
|
|
5
|
+
import { createElement, useEffect, useRef } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
8
|
+
import { createContextScope } from './context';
|
|
9
|
+
import { S, subSlot } from './internal';
|
|
10
|
+
import { Presence } from './Presence';
|
|
11
|
+
import { Primitive } from './Primitive';
|
|
12
|
+
import * as RovingFocusGroup from './RovingFocusGroup';
|
|
13
|
+
import { createRovingFocusGroupScope } from './RovingFocusGroup';
|
|
14
|
+
import { useControllableState } from './useControllableState';
|
|
15
|
+
import { useId } from './useId';
|
|
16
|
+
|
|
17
|
+
const [createTabsContext, createTabsScope] = createContextScope('Tabs', [
|
|
18
|
+
createRovingFocusGroupScope,
|
|
19
|
+
]);
|
|
20
|
+
export { createTabsScope };
|
|
21
|
+
const useRovingFocusGroupScope = createRovingFocusGroupScope();
|
|
22
|
+
|
|
23
|
+
interface TabsContextValue {
|
|
24
|
+
baseId: string;
|
|
25
|
+
value?: string;
|
|
26
|
+
onValueChange: (value: string) => void;
|
|
27
|
+
orientation?: 'horizontal' | 'vertical';
|
|
28
|
+
dir?: 'ltr' | 'rtl';
|
|
29
|
+
activationMode?: 'automatic' | 'manual';
|
|
30
|
+
}
|
|
31
|
+
const [TabsProvider, useTabsContext] = createTabsContext<TabsContextValue>('Tabs');
|
|
32
|
+
|
|
33
|
+
export function Root(props: any): any {
|
|
34
|
+
const slot = S('Tabs.Root');
|
|
35
|
+
const {
|
|
36
|
+
__scopeTabs,
|
|
37
|
+
value: valueProp,
|
|
38
|
+
onValueChange,
|
|
39
|
+
defaultValue,
|
|
40
|
+
orientation = 'horizontal',
|
|
41
|
+
dir,
|
|
42
|
+
activationMode = 'automatic',
|
|
43
|
+
...tabsProps
|
|
44
|
+
} = props ?? {};
|
|
45
|
+
const direction = dir === 'rtl' ? 'rtl' : 'ltr';
|
|
46
|
+
const [value, setValue] = useControllableState<string>(
|
|
47
|
+
{ prop: valueProp, onChange: onValueChange, defaultProp: defaultValue ?? '' },
|
|
48
|
+
subSlot(slot, 'value'),
|
|
49
|
+
);
|
|
50
|
+
return createElement(TabsProvider, {
|
|
51
|
+
scope: __scopeTabs,
|
|
52
|
+
baseId: useId(subSlot(slot, 'id')),
|
|
53
|
+
value,
|
|
54
|
+
onValueChange: setValue,
|
|
55
|
+
orientation,
|
|
56
|
+
dir: direction,
|
|
57
|
+
activationMode,
|
|
58
|
+
children: createElement(Primitive.div, {
|
|
59
|
+
dir: direction,
|
|
60
|
+
'data-orientation': orientation,
|
|
61
|
+
...tabsProps,
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function List(props: any): any {
|
|
67
|
+
const slot = S('Tabs.List');
|
|
68
|
+
const { __scopeTabs, loop = true, ...listProps } = props ?? {};
|
|
69
|
+
const context = useTabsContext('TabsList', __scopeTabs);
|
|
70
|
+
const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTabs, subSlot(slot, 'rfs'));
|
|
71
|
+
return createElement(RovingFocusGroup.Root, {
|
|
72
|
+
asChild: true,
|
|
73
|
+
...rovingFocusGroupScope,
|
|
74
|
+
orientation: context.orientation,
|
|
75
|
+
dir: context.dir,
|
|
76
|
+
loop,
|
|
77
|
+
children: createElement(Primitive.div, {
|
|
78
|
+
role: 'tablist',
|
|
79
|
+
'aria-orientation': context.orientation,
|
|
80
|
+
...listProps,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Trigger(props: any): any {
|
|
86
|
+
const slot = S('Tabs.Trigger');
|
|
87
|
+
const { __scopeTabs, value, disabled = false, ...triggerProps } = props ?? {};
|
|
88
|
+
const context = useTabsContext('TabsTrigger', __scopeTabs);
|
|
89
|
+
const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeTabs, subSlot(slot, 'rfs'));
|
|
90
|
+
const triggerId = makeTriggerId(context.baseId, value);
|
|
91
|
+
const contentId = makeContentId(context.baseId, value);
|
|
92
|
+
const isSelected = value === context.value;
|
|
93
|
+
return createElement(RovingFocusGroup.Item, {
|
|
94
|
+
asChild: true,
|
|
95
|
+
...rovingFocusGroupScope,
|
|
96
|
+
focusable: !disabled,
|
|
97
|
+
active: isSelected,
|
|
98
|
+
children: createElement(Primitive.button, {
|
|
99
|
+
type: 'button',
|
|
100
|
+
role: 'tab',
|
|
101
|
+
'aria-selected': isSelected,
|
|
102
|
+
'aria-controls': contentId,
|
|
103
|
+
'data-state': isSelected ? 'active' : 'inactive',
|
|
104
|
+
'data-disabled': disabled ? '' : undefined,
|
|
105
|
+
disabled,
|
|
106
|
+
id: triggerId,
|
|
107
|
+
...triggerProps,
|
|
108
|
+
onMouseDown: composeEventHandlers(props?.onMouseDown, (event: MouseEvent) => {
|
|
109
|
+
// Only activate on left-click without ctrl (right-click / ctrl-click are
|
|
110
|
+
// context-menu gestures).
|
|
111
|
+
if (!disabled && event.button === 0 && event.ctrlKey === false) {
|
|
112
|
+
context.onValueChange(value);
|
|
113
|
+
} else {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
|
|
118
|
+
if ([' ', 'Enter'].includes(event.key)) context.onValueChange(value);
|
|
119
|
+
}),
|
|
120
|
+
onFocus: composeEventHandlers(props?.onFocus, () => {
|
|
121
|
+
// Automatic activation: focusing a tab (e.g. via arrow keys) selects it.
|
|
122
|
+
const isAutomaticActivation = context.activationMode !== 'manual';
|
|
123
|
+
if (!isSelected && !disabled && isAutomaticActivation) {
|
|
124
|
+
context.onValueChange(value);
|
|
125
|
+
}
|
|
126
|
+
}),
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function Content(props: any): any {
|
|
132
|
+
const slot = S('Tabs.Content');
|
|
133
|
+
const { __scopeTabs, value, forceMount, children, ...contentProps } = props ?? {};
|
|
134
|
+
const context = useTabsContext('TabsContent', __scopeTabs);
|
|
135
|
+
const triggerId = makeTriggerId(context.baseId, value);
|
|
136
|
+
const contentId = makeContentId(context.baseId, value);
|
|
137
|
+
const isSelected = value === context.value;
|
|
138
|
+
const isMountAnimationPreventedRef = useRef(isSelected, subSlot(slot, 'prevent'));
|
|
139
|
+
|
|
140
|
+
useEffect(
|
|
141
|
+
() => {
|
|
142
|
+
const rAF = requestAnimationFrame(() => (isMountAnimationPreventedRef.current = false));
|
|
143
|
+
return () => cancelAnimationFrame(rAF);
|
|
144
|
+
},
|
|
145
|
+
[],
|
|
146
|
+
subSlot(slot, 'e:mount'),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return createElement(Presence, {
|
|
150
|
+
present: forceMount || isSelected,
|
|
151
|
+
children: ({ present }: { present: boolean }) =>
|
|
152
|
+
createElement(Primitive.div, {
|
|
153
|
+
'data-state': isSelected ? 'active' : 'inactive',
|
|
154
|
+
'data-orientation': context.orientation,
|
|
155
|
+
role: 'tabpanel',
|
|
156
|
+
'aria-labelledby': triggerId,
|
|
157
|
+
hidden: !present,
|
|
158
|
+
id: contentId,
|
|
159
|
+
tabIndex: 0,
|
|
160
|
+
...contentProps,
|
|
161
|
+
style: {
|
|
162
|
+
...props.style,
|
|
163
|
+
animationDuration: isMountAnimationPreventedRef.current ? '0s' : undefined,
|
|
164
|
+
},
|
|
165
|
+
children: present ? children : null,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeTriggerId(baseId: string, value: string): string {
|
|
171
|
+
return `${baseId}-trigger-${value}`;
|
|
172
|
+
}
|
|
173
|
+
function makeContentId(baseId: string, value: string): string {
|
|
174
|
+
return `${baseId}-content-${value}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { Root as Tabs };
|