@octanejs/base-ui 0.1.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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -0
  3. package/package.json +47 -0
  4. package/src/avatar.ts +308 -0
  5. package/src/checkbox-group.ts +183 -0
  6. package/src/checkbox.ts +578 -0
  7. package/src/field.ts +676 -0
  8. package/src/fieldset.ts +150 -0
  9. package/src/form.ts +183 -0
  10. package/src/index.ts +23 -0
  11. package/src/input.ts +9 -0
  12. package/src/internal.ts +42 -0
  13. package/src/merge-props.ts +2 -0
  14. package/src/meter.ts +204 -0
  15. package/src/number-field.ts +978 -0
  16. package/src/progress.ts +260 -0
  17. package/src/radio-group.ts +285 -0
  18. package/src/radio.ts +401 -0
  19. package/src/separator.ts +43 -0
  20. package/src/slider.ts +1617 -0
  21. package/src/switch.ts +374 -0
  22. package/src/toggle-group.ts +136 -0
  23. package/src/toggle.ts +145 -0
  24. package/src/use-render.ts +40 -0
  25. package/src/utils/CheckboxGroupContext.ts +30 -0
  26. package/src/utils/CompositeRootContext.ts +27 -0
  27. package/src/utils/DirectionContext.ts +15 -0
  28. package/src/utils/RadioGroupContext.ts +27 -0
  29. package/src/utils/ToggleGroupContext.ts +33 -0
  30. package/src/utils/addEventListener.ts +13 -0
  31. package/src/utils/areArraysEqual.ts +11 -0
  32. package/src/utils/clamp.ts +8 -0
  33. package/src/utils/composeRefs.ts +53 -0
  34. package/src/utils/composite/CompositeItem.ts +56 -0
  35. package/src/utils/composite/CompositeList.ts +190 -0
  36. package/src/utils/composite/CompositeListContext.ts +23 -0
  37. package/src/utils/composite/CompositeRoot.ts +132 -0
  38. package/src/utils/composite/keys.ts +179 -0
  39. package/src/utils/composite/list-utils.ts +78 -0
  40. package/src/utils/composite/useCompositeItem.ts +58 -0
  41. package/src/utils/composite/useCompositeListItem.ts +108 -0
  42. package/src/utils/composite/useCompositeRoot.ts +346 -0
  43. package/src/utils/contains.ts +22 -0
  44. package/src/utils/createChangeEventDetails.ts +81 -0
  45. package/src/utils/field/FieldItemContext.ts +13 -0
  46. package/src/utils/field/FieldRootContext.ts +98 -0
  47. package/src/utils/field/FormContext.ts +37 -0
  48. package/src/utils/field/LabelableContext.ts +32 -0
  49. package/src/utils/field/LabelableProvider.ts +108 -0
  50. package/src/utils/field/constants.ts +80 -0
  51. package/src/utils/field/getCombinedFieldValidityData.ts +16 -0
  52. package/src/utils/field/useAriaLabelledBy.ts +86 -0
  53. package/src/utils/field/useFieldControlRegistration.ts +179 -0
  54. package/src/utils/field/useFieldValidation.ts +311 -0
  55. package/src/utils/field/useLabel.ts +95 -0
  56. package/src/utils/field/useLabelableId.ts +99 -0
  57. package/src/utils/field/useRegisterFieldControl.ts +41 -0
  58. package/src/utils/formatNumber.ts +44 -0
  59. package/src/utils/getDefaultFormSubmitter.ts +19 -0
  60. package/src/utils/getElementRef.ts +13 -0
  61. package/src/utils/getStateAttributesProps.ts +34 -0
  62. package/src/utils/matchesFocusVisible.ts +16 -0
  63. package/src/utils/mergeObjects.ts +18 -0
  64. package/src/utils/mergeProps.ts +206 -0
  65. package/src/utils/noop.ts +4 -0
  66. package/src/utils/number/constants.ts +4 -0
  67. package/src/utils/number/parse.ts +227 -0
  68. package/src/utils/number/types.ts +25 -0
  69. package/src/utils/number/validate.ts +129 -0
  70. package/src/utils/owner.ts +9 -0
  71. package/src/utils/platform.ts +15 -0
  72. package/src/utils/resolveAriaLabelledBy.ts +11 -0
  73. package/src/utils/resolveClassName.ts +8 -0
  74. package/src/utils/resolveRef.ts +10 -0
  75. package/src/utils/resolveStyle.ts +10 -0
  76. package/src/utils/serializeValue.ts +15 -0
  77. package/src/utils/slider/asc.ts +4 -0
  78. package/src/utils/slider/getMidpoint.ts +14 -0
  79. package/src/utils/slider/getPushedThumbValues.ts +73 -0
  80. package/src/utils/slider/getSliderValue.ts +27 -0
  81. package/src/utils/slider/replaceArrayItemAtIndex.ts +8 -0
  82. package/src/utils/slider/resolveThumbCollision.ts +178 -0
  83. package/src/utils/slider/roundValueToStep.ts +22 -0
  84. package/src/utils/slider/validateMinimumDistance.ts +22 -0
  85. package/src/utils/slider/valueArrayToPercentages.ts +11 -0
  86. package/src/utils/stringifyLocale.ts +10 -0
  87. package/src/utils/useAnimationFrame.ts +59 -0
  88. package/src/utils/useAnimationsFinished.ts +100 -0
  89. package/src/utils/useBaseUiId.ts +19 -0
  90. package/src/utils/useButton.ts +217 -0
  91. package/src/utils/useCheckboxGroupParent.ts +113 -0
  92. package/src/utils/useControlled.ts +42 -0
  93. package/src/utils/useFocusableWhenDisabled.ts +84 -0
  94. package/src/utils/useForcedRerendering.ts +11 -0
  95. package/src/utils/useIsHydrating.ts +9 -0
  96. package/src/utils/useOpenChangeComplete.ts +46 -0
  97. package/src/utils/usePressAndHold.ts +35 -0
  98. package/src/utils/useRefWithInit.ts +23 -0
  99. package/src/utils/useRegisteredLabelId.ts +32 -0
  100. package/src/utils/useRenderElement.ts +165 -0
  101. package/src/utils/useStableCallback.ts +26 -0
  102. package/src/utils/useTimeout.ts +51 -0
  103. package/src/utils/useTransitionStatus.ts +115 -0
  104. package/src/utils/useValueAsRef.ts +25 -0
  105. package/src/utils/useValueChanged.ts +37 -0
  106. package/src/utils/valueToPercent.ts +4 -0
  107. package/src/utils/visuallyHidden.ts +24 -0
package/src/switch.ts ADDED
@@ -0,0 +1,374 @@
1
+ // Ported from .base-ui/packages/react/src/switch/ (v1.6.0): root/SwitchRoot,
2
+ // root/SwitchRootContext, stateAttributesMapping, thumb/SwitchThumb — plus its `index.parts`
3
+ // (the `Switch` namespace).
4
+ //
5
+ // A switch renders a `<span role="switch">` plus a hidden `<input type="checkbox">`. octane
6
+ // adaptations: (1) events are NATIVE — the handlers read the event directly (Base UI's
7
+ // `event.nativeEvent`); (2) octane inputs are UNCONTROLLED (a `checked` prop would write a
8
+ // `checked` ATTRIBUTE, which React's controlled input never does), so we DON'T pass `checked`
9
+ // to the input and instead drive `input.checked` imperatively through the native property
10
+ // setter in a layout effect — matching React's property-only controlled behavior. Field/Form
11
+ // integration is inert when standalone (default contexts).
12
+ import { createContext, createElement, useContext, useLayoutEffect, useMemo, useRef } from 'octane';
13
+
14
+ import { S, subSlot } from './internal';
15
+ import { useRenderElement, type RenderProp } from './utils/useRenderElement';
16
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
17
+ import { mergeProps } from './utils/mergeProps';
18
+ import { useBaseUiId } from './utils/useBaseUiId';
19
+ import { useButton } from './utils/useButton';
20
+ import { useControlled } from './utils/useControlled';
21
+ import { useComposedRefs } from './utils/composeRefs';
22
+ import { visuallyHidden, visuallyHiddenInput } from './utils/visuallyHidden';
23
+ import { ownerWindow } from './utils/owner';
24
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
25
+ import { fieldValidityMapping, type FieldRootState } from './utils/field/constants';
26
+ import { useFieldRootContext } from './utils/field/FieldRootContext';
27
+ import { useFormContext } from './utils/field/FormContext';
28
+ import { useLabelableContext } from './utils/field/LabelableContext';
29
+ import { useRegisterFieldControl } from './utils/field/useRegisterFieldControl';
30
+ import { useAriaLabelledBy } from './utils/field/useAriaLabelledBy';
31
+ import { useLabelableId } from './utils/field/useLabelableId';
32
+ import { useValueChanged } from './utils/useValueChanged';
33
+
34
+ export interface SwitchRootState extends FieldRootState {
35
+ checked: boolean;
36
+ readOnly: boolean;
37
+ required: boolean;
38
+ }
39
+
40
+ const stateAttributesMapping: StateAttributesMapping<SwitchRootState> = {
41
+ ...(fieldValidityMapping as StateAttributesMapping<any>),
42
+ checked(value: boolean): Record<string, string> {
43
+ if (value) {
44
+ return { 'data-checked': '' };
45
+ }
46
+ return { 'data-unchecked': '' };
47
+ },
48
+ };
49
+
50
+ // --- Context -----------------------------------------------------------------
51
+
52
+ const SwitchRootContext = createContext<SwitchRootState | undefined>(undefined);
53
+
54
+ function useSwitchRootContext(): SwitchRootState {
55
+ const context = useContext(SwitchRootContext);
56
+ if (context === undefined) {
57
+ throw new Error(
58
+ 'Base UI: SwitchRootContext is missing. Switch parts must be placed within <Switch.Root>.',
59
+ );
60
+ }
61
+ return context;
62
+ }
63
+
64
+ // --- Root --------------------------------------------------------------------
65
+
66
+ export interface SwitchRootProps {
67
+ checked?: boolean;
68
+ defaultChecked?: boolean;
69
+ disabled?: boolean;
70
+ readOnly?: boolean;
71
+ required?: boolean;
72
+ name?: string;
73
+ form?: string;
74
+ id?: string;
75
+ inputRef?: any;
76
+ nativeButton?: boolean;
77
+ value?: string;
78
+ uncheckedValue?: string;
79
+ onCheckedChange?: (checked: boolean, eventDetails: any) => void;
80
+ 'aria-labelledby'?: string;
81
+ render?: RenderProp<SwitchRootState>;
82
+ className?: string | ((state: SwitchRootState) => string | undefined);
83
+ style?: Record<string, any> | ((state: SwitchRootState) => Record<string, any> | undefined);
84
+ ref?: any;
85
+ [key: string]: any;
86
+ }
87
+
88
+ function SwitchRoot(props: SwitchRootProps): any {
89
+ const slot = S('SwitchRoot');
90
+ const {
91
+ checked: checkedProp,
92
+ className,
93
+ defaultChecked,
94
+ 'aria-labelledby': ariaLabelledByProp,
95
+ form,
96
+ id: idProp,
97
+ inputRef: externalInputRef,
98
+ name: nameProp,
99
+ nativeButton = false,
100
+ onCheckedChange,
101
+ readOnly = false,
102
+ required = false,
103
+ disabled: disabledProp = false,
104
+ render,
105
+ uncheckedValue,
106
+ value,
107
+ style,
108
+ ref,
109
+ ...elementProps
110
+ } = props;
111
+
112
+ const { clearErrors } = useFormContext();
113
+ const {
114
+ state: fieldState,
115
+ setTouched,
116
+ setDirty,
117
+ validityData,
118
+ setFilled,
119
+ setFocused,
120
+ validationMode,
121
+ disabled: fieldDisabled,
122
+ name: fieldName,
123
+ validation,
124
+ } = useFieldRootContext();
125
+ const { labelId } = useLabelableContext();
126
+
127
+ const disabled = fieldDisabled || disabledProp;
128
+ const name = fieldName ?? nameProp;
129
+
130
+ const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
131
+ const handleInputRef = useComposedRefs(
132
+ inputRef,
133
+ externalInputRef,
134
+ validation.inputRef,
135
+ subSlot(slot, 'handleInputRef'),
136
+ );
137
+
138
+ const switchRef = useRef<HTMLButtonElement | null>(null, subSlot(slot, 'switchRef'));
139
+
140
+ const id = useBaseUiId(undefined, subSlot(slot, 'id'));
141
+
142
+ const controlId = useLabelableId(
143
+ { id: idProp, implicit: false, controlRef: switchRef },
144
+ subSlot(slot, 'controlId'),
145
+ );
146
+ const hiddenInputId = nativeButton ? undefined : controlId;
147
+
148
+ const [checked, setCheckedState] = useControlled<boolean>(
149
+ {
150
+ controlled: checkedProp,
151
+ default: Boolean(defaultChecked),
152
+ name: 'Switch',
153
+ state: 'checked',
154
+ },
155
+ subSlot(slot, 'checked'),
156
+ );
157
+
158
+ // octane: React's controlled `<input checked>` reflects the INITIAL checked to the
159
+ // `checked` ATTRIBUTE (its default-state), then updates only the property. Capture that
160
+ // initial value so the attribute matches; the property is driven by the sync effect below.
161
+ const initialCheckedRef = useRef(checked, subSlot(slot, 'initialChecked'));
162
+
163
+ useRegisterFieldControl(
164
+ switchRef,
165
+ id,
166
+ checked,
167
+ undefined,
168
+ !disabled,
169
+ nameProp,
170
+ subSlot(slot, 'register'),
171
+ );
172
+
173
+ useLayoutEffect(
174
+ () => {
175
+ if (inputRef.current) {
176
+ setFilled(inputRef.current.checked);
177
+ }
178
+ },
179
+ [inputRef, setFilled],
180
+ subSlot(slot, 'e:filled'),
181
+ );
182
+
183
+ // octane uncontrolled-input adaptation: drive the hidden input's `checked` PROPERTY (never
184
+ // the attribute) through the native setter, so the DOM matches React's controlled input.
185
+ useLayoutEffect(
186
+ () => {
187
+ const input = inputRef.current;
188
+ if (!input) {
189
+ return;
190
+ }
191
+ const setNativeChecked = Object.getOwnPropertyDescriptor(
192
+ ownerWindow(input).HTMLInputElement.prototype,
193
+ 'checked',
194
+ )?.set;
195
+ setNativeChecked?.call(input, checked);
196
+ },
197
+ [checked],
198
+ subSlot(slot, 'e:syncChecked'),
199
+ );
200
+
201
+ useValueChanged(
202
+ checked,
203
+ () => {
204
+ clearErrors(name);
205
+ setDirty(checked !== validityData.initialValue);
206
+ setFilled(checked);
207
+ validation.change(checked);
208
+ },
209
+ subSlot(slot, 'valueChanged'),
210
+ );
211
+
212
+ const { getButtonProps, buttonRef } = useButton(
213
+ { disabled, native: nativeButton },
214
+ subSlot(slot, 'button'),
215
+ );
216
+ const ariaLabelledBy = useAriaLabelledBy(
217
+ ariaLabelledByProp,
218
+ labelId,
219
+ inputRef,
220
+ !nativeButton,
221
+ hiddenInputId,
222
+ subSlot(slot, 'ariaLabelledBy'),
223
+ );
224
+
225
+ const rootProps: Record<string, any> = {
226
+ id: nativeButton ? controlId : id,
227
+ role: 'switch',
228
+ 'aria-checked': checked,
229
+ 'aria-readonly': readOnly || undefined,
230
+ 'aria-required': required || undefined,
231
+ 'aria-labelledby': ariaLabelledBy,
232
+ onFocus() {
233
+ if (!disabled) {
234
+ setFocused(true);
235
+ }
236
+ },
237
+ onBlur() {
238
+ const element = inputRef.current;
239
+ if (!element || disabled) {
240
+ return;
241
+ }
242
+ setTouched(true);
243
+ setFocused(false);
244
+ if (validationMode === 'onBlur') {
245
+ validation.commit(element.checked);
246
+ }
247
+ },
248
+ onClick(event: any) {
249
+ if (readOnly || disabled) {
250
+ return;
251
+ }
252
+ event.preventDefault();
253
+ const input = inputRef.current;
254
+ if (!input) {
255
+ return;
256
+ }
257
+ input.dispatchEvent(
258
+ new (ownerWindow(input).PointerEvent)('click', {
259
+ bubbles: true,
260
+ shiftKey: event.shiftKey,
261
+ ctrlKey: event.ctrlKey,
262
+ altKey: event.altKey,
263
+ metaKey: event.metaKey,
264
+ }),
265
+ );
266
+ },
267
+ };
268
+
269
+ const inputProps: Record<string, any> = mergeProps(
270
+ {
271
+ // octane: the INITIAL checked state is the `checked` ATTRIBUTE (React's
272
+ // default-state); the live value is driven via the property in the sync effect.
273
+ checked: initialCheckedRef.current || undefined,
274
+ disabled,
275
+ form,
276
+ id: hiddenInputId,
277
+ name,
278
+ required,
279
+ style: name ? visuallyHiddenInput : visuallyHidden,
280
+ tabIndex: -1,
281
+ type: 'checkbox',
282
+ 'aria-hidden': true,
283
+ ref: handleInputRef,
284
+ onChange(event: any) {
285
+ // octane: the handler receives the native event directly.
286
+ if (event.defaultPrevented) {
287
+ return;
288
+ }
289
+ if (readOnly) {
290
+ event.preventDefault();
291
+ return;
292
+ }
293
+ const nextChecked = event.currentTarget.checked;
294
+ const eventDetails = createChangeEventDetails(REASONS.none, event);
295
+ onCheckedChange?.(nextChecked, eventDetails);
296
+ if (eventDetails.isCanceled) {
297
+ return;
298
+ }
299
+ setCheckedState(nextChecked);
300
+ },
301
+ onFocus() {
302
+ switchRef.current?.focus();
303
+ },
304
+ },
305
+ (p: any) => validation.getValidationProps(disabled, p),
306
+ value !== undefined ? { value } : {},
307
+ );
308
+
309
+ const state: SwitchRootState = useMemo(
310
+ () => ({
311
+ ...fieldState,
312
+ checked,
313
+ disabled,
314
+ readOnly,
315
+ required,
316
+ }),
317
+ [fieldState, checked, disabled, readOnly, required],
318
+ subSlot(slot, 'state'),
319
+ );
320
+
321
+ const element = useRenderElement(
322
+ 'span',
323
+ { render, className, style },
324
+ {
325
+ state,
326
+ ref: [ref, switchRef, buttonRef],
327
+ props: [
328
+ rootProps,
329
+ elementProps,
330
+ getButtonProps,
331
+ (p: any) => validation.getValidationProps(disabled, p),
332
+ ],
333
+ stateAttributesMapping,
334
+ },
335
+ subSlot(slot, 're'),
336
+ );
337
+
338
+ const hiddenValueInput =
339
+ !checked && name && uncheckedValue !== undefined
340
+ ? createElement('input', {
341
+ type: 'hidden',
342
+ form,
343
+ name,
344
+ value: uncheckedValue,
345
+ disabled,
346
+ })
347
+ : null;
348
+
349
+ return createElement(SwitchRootContext.Provider, {
350
+ value: state,
351
+ children: [element, hiddenValueInput, createElement('input', inputProps)],
352
+ });
353
+ }
354
+
355
+ // --- Thumb -------------------------------------------------------------------
356
+
357
+ function SwitchThumb(props: any): any {
358
+ const slot = S('SwitchThumb');
359
+ const { render, className, style, ref, ...elementProps } = props;
360
+ const state = useSwitchRootContext();
361
+ return useRenderElement(
362
+ 'span',
363
+ { render, className, style },
364
+ { state, ref, props: elementProps, stateAttributesMapping },
365
+ subSlot(slot, 're'),
366
+ );
367
+ }
368
+
369
+ // --- Namespace (mirrors `export * as Switch`) --------------------------------
370
+
371
+ export const Switch = {
372
+ Root: SwitchRoot,
373
+ Thumb: SwitchThumb,
374
+ };
@@ -0,0 +1,136 @@
1
+ // Ported from .base-ui/packages/react/src/toggle-group/ToggleGroup.tsx (v1.6.0). Provides a
2
+ // shared value to a set of <Toggle>s and manages roving focus via CompositeRoot. Standalone
3
+ // (no Toolbar) it renders `<CompositeRoot>` as a `role="group"` div; the Toolbar path (which
4
+ // renders the plain `element`) lands with Toolbar in a later phase.
5
+ //
6
+ // octane: forwardRef → ref-as-prop.
7
+ import { createElement, useMemo } from 'octane';
8
+
9
+ import { S, subSlot } from './internal';
10
+ import { useRenderElement, type RenderProp } from './utils/useRenderElement';
11
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
12
+ import { useControlled } from './utils/useControlled';
13
+ import { useStableCallback } from './utils/useStableCallback';
14
+ import { CompositeRoot } from './utils/composite/CompositeRoot';
15
+ import { ToggleGroupContext, type ToggleGroupContextValue } from './utils/ToggleGroupContext';
16
+
17
+ const EMPTY_ARRAY: never[] = [];
18
+
19
+ export interface ToggleGroupState {
20
+ disabled: boolean;
21
+ multiple: boolean;
22
+ orientation: 'horizontal' | 'vertical';
23
+ }
24
+
25
+ const stateAttributesMapping: StateAttributesMapping<ToggleGroupState> = {
26
+ multiple(value: boolean): Record<string, string> | null {
27
+ if (value) {
28
+ return { 'data-multiple': '' };
29
+ }
30
+ return null;
31
+ },
32
+ };
33
+
34
+ export interface ToggleGroupProps<Value extends string = string> {
35
+ value?: readonly Value[];
36
+ defaultValue?: readonly Value[];
37
+ disabled?: boolean;
38
+ loopFocus?: boolean;
39
+ onValueChange?: (groupValue: Value[], eventDetails: any) => void;
40
+ orientation?: 'horizontal' | 'vertical';
41
+ multiple?: boolean;
42
+ render?: RenderProp<ToggleGroupState>;
43
+ className?: string | ((state: ToggleGroupState) => string | undefined);
44
+ style?: Record<string, any> | ((state: ToggleGroupState) => Record<string, any> | undefined);
45
+ ref?: any;
46
+ [key: string]: any;
47
+ }
48
+
49
+ function ToggleGroup<Value extends string = string>(props: ToggleGroupProps<Value>): any {
50
+ const slot = S('ToggleGroup');
51
+ const {
52
+ defaultValue: defaultValueProp,
53
+ disabled: disabledProp = false,
54
+ loopFocus = true,
55
+ onValueChange,
56
+ orientation = 'horizontal',
57
+ multiple = false,
58
+ value: valueProp,
59
+ className,
60
+ render,
61
+ style,
62
+ ref,
63
+ ...elementProps
64
+ } = props;
65
+
66
+ // Toolbar contexts (Phase-later) are always absent standalone.
67
+ const isValueInitialized = useMemo(
68
+ () => valueProp !== undefined || defaultValueProp !== undefined,
69
+ [valueProp, defaultValueProp],
70
+ subSlot(slot, 'init'),
71
+ );
72
+
73
+ const disabled = disabledProp;
74
+
75
+ const [groupValue, setValueState] = useControlled<readonly Value[]>(
76
+ {
77
+ controlled: valueProp,
78
+ default: valueProp === undefined ? (defaultValueProp ?? EMPTY_ARRAY) : undefined,
79
+ name: 'ToggleGroup',
80
+ state: 'value',
81
+ },
82
+ subSlot(slot, 'value'),
83
+ );
84
+
85
+ const setGroupValue = useStableCallback(
86
+ (newValue: Value, nextPressed: boolean, eventDetails: any) => {
87
+ let newGroupValue: Value[];
88
+ if (multiple) {
89
+ newGroupValue = groupValue.slice();
90
+ if (nextPressed) {
91
+ newGroupValue.push(newValue);
92
+ } else {
93
+ newGroupValue.splice(groupValue.indexOf(newValue), 1);
94
+ }
95
+ } else {
96
+ newGroupValue = nextPressed ? [newValue] : [];
97
+ }
98
+ onValueChange?.(newGroupValue, eventDetails);
99
+ if (eventDetails.isCanceled) {
100
+ return;
101
+ }
102
+ setValueState(newGroupValue);
103
+ },
104
+ subSlot(slot, 'setGroup'),
105
+ );
106
+
107
+ const state: ToggleGroupState = { disabled, multiple, orientation };
108
+
109
+ const contextValue: ToggleGroupContextValue<Value> = useMemo(
110
+ () => ({ disabled, orientation, setGroupValue, value: groupValue, isValueInitialized }),
111
+ [disabled, orientation, setGroupValue, groupValue, isValueInitialized],
112
+ subSlot(slot, 'ctx'),
113
+ );
114
+
115
+ const defaultProps = { role: 'group' };
116
+
117
+ const compositeRoot = createElement(CompositeRoot, {
118
+ render,
119
+ className,
120
+ style,
121
+ state,
122
+ refs: [ref],
123
+ props: [defaultProps, elementProps],
124
+ stateAttributesMapping,
125
+ loopFocus,
126
+ enableHomeAndEndKeys: true,
127
+ orientation,
128
+ });
129
+
130
+ return createElement(ToggleGroupContext.Provider, {
131
+ value: contextValue,
132
+ children: compositeRoot,
133
+ });
134
+ }
135
+
136
+ export { ToggleGroup };
package/src/toggle.ts ADDED
@@ -0,0 +1,145 @@
1
+ // Ported from .base-ui/packages/react/src/toggle/Toggle.tsx (v1.6.0). A two-state button
2
+ // (`aria-pressed`). Standalone it is uncontrolled/controlled via `pressed`/`defaultPressed`;
3
+ // inside a <ToggleGroup> it derives pressed from the group value and renders through
4
+ // CompositeItem (that group path lands with ToggleGroup — here the standalone path is
5
+ // complete and the group branch is wired to the shared context).
6
+ //
7
+ // octane adaptations: forwardRef → ref-as-prop; the click handler receives the NATIVE
8
+ // event directly (Base UI reads `event.nativeEvent` off the synthetic wrapper — octane has
9
+ // none, so we pass the native event straight into the change-details); the dev
10
+ // group-value warning is dropped.
11
+ import { createElement, useMemo } from 'octane';
12
+
13
+ import { S, subSlot } from './internal';
14
+ import { useRenderElement, type RenderProp } from './utils/useRenderElement';
15
+ import { useBaseUiId } from './utils/useBaseUiId';
16
+ import { useButton } from './utils/useButton';
17
+ import { useControlled } from './utils/useControlled';
18
+ import { useToggleGroupContext } from './utils/ToggleGroupContext';
19
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
20
+ import { CompositeItem } from './utils/composite/CompositeItem';
21
+
22
+ export interface ToggleState {
23
+ pressed: boolean;
24
+ disabled: boolean;
25
+ }
26
+
27
+ export interface ToggleProps {
28
+ pressed?: boolean;
29
+ defaultPressed?: boolean;
30
+ disabled?: boolean;
31
+ onPressedChange?: (pressed: boolean, eventDetails: any) => void;
32
+ value?: string;
33
+ nativeButton?: boolean;
34
+ render?: RenderProp<ToggleState>;
35
+ className?: string | ((state: ToggleState) => string | undefined);
36
+ style?: Record<string, any> | ((state: ToggleState) => Record<string, any> | undefined);
37
+ ref?: any;
38
+ [key: string]: any;
39
+ }
40
+
41
+ function Toggle(props: ToggleProps): any {
42
+ const slot = S('Toggle');
43
+ const {
44
+ className,
45
+ defaultPressed: defaultPressedProp = false,
46
+ disabled: disabledProp = false,
47
+ form, // never participates in form validation
48
+ onPressedChange,
49
+ pressed: pressedProp,
50
+ render,
51
+ type, // cannot change button type
52
+ value: valueProp,
53
+ nativeButton = true,
54
+ style,
55
+ ...elementProps
56
+ } = props;
57
+
58
+ // `|| undefined` handles a falsy value (e.g. "").
59
+ const value = useBaseUiId(valueProp || undefined, subSlot(slot, 'value'));
60
+ const groupContext = useToggleGroupContext();
61
+ const groupValue = groupContext?.value ?? [];
62
+
63
+ const defaultPressed = groupContext ? undefined : defaultPressedProp;
64
+ const disabled = (disabledProp || groupContext?.disabled) ?? false;
65
+
66
+ const [pressed, setPressedState] = useControlled<boolean>(
67
+ {
68
+ controlled: groupContext
69
+ ? value !== undefined && groupValue.indexOf(value) > -1
70
+ : pressedProp,
71
+ default: defaultPressed,
72
+ name: 'Toggle',
73
+ state: 'pressed',
74
+ },
75
+ subSlot(slot, 'pressed'),
76
+ );
77
+
78
+ const { getButtonProps, buttonRef } = useButton(
79
+ { disabled, native: nativeButton },
80
+ subSlot(slot, 'btn'),
81
+ );
82
+
83
+ const state: ToggleState = { disabled, pressed };
84
+
85
+ const refs = [buttonRef, props.ref];
86
+ const buttonProps = [
87
+ {
88
+ 'aria-pressed': pressed,
89
+ onClick(event: any) {
90
+ const nextPressed = !pressed;
91
+ // octane: the click handler receives the native event directly.
92
+ const details = createChangeEventDetails(REASONS.none, event);
93
+
94
+ // `onPressedChange` runs before the group commits so canceling here can also
95
+ // veto the group value change (they share this `details` object).
96
+ onPressedChange?.(nextPressed, details);
97
+ if (details.isCanceled) {
98
+ return;
99
+ }
100
+ if (value) {
101
+ groupContext?.setGroupValue?.(value, nextPressed, details);
102
+ }
103
+ if (details.isCanceled) {
104
+ return;
105
+ }
106
+ setPressedState(nextPressed);
107
+ },
108
+ },
109
+ elementProps,
110
+ getButtonProps,
111
+ ];
112
+
113
+ const element = useRenderElement(
114
+ 'button',
115
+ { render, className, style },
116
+ { enabled: !groupContext, state, ref: refs, props: buttonProps },
117
+ subSlot(slot, 're'),
118
+ );
119
+
120
+ // A disabled toggle is natively disabled and cannot hold roving focus. Toolbar reads this
121
+ // to compute `disabledIndices` (consumed by the group/CompositeItem path).
122
+ const itemMetadata = useMemo(
123
+ () => ({ disabled, focusableWhenDisabled: false }),
124
+ [disabled],
125
+ subSlot(slot, 'meta'),
126
+ );
127
+
128
+ if (groupContext) {
129
+ // Group path: render through CompositeItem for roving focus.
130
+ return createElement(CompositeItem, {
131
+ tag: 'button',
132
+ render,
133
+ className,
134
+ style,
135
+ metadata: itemMetadata,
136
+ state,
137
+ refs,
138
+ props: buttonProps,
139
+ });
140
+ }
141
+
142
+ return element;
143
+ }
144
+
145
+ export { Toggle };
@@ -0,0 +1,40 @@
1
+ // Public entry — mirrors @base-ui/react/use-render
2
+ // (.base-ui/packages/react/src/use-render/useRender.ts). Build a custom element with
3
+ // Base UI's render-prop + state→data-* + prop-merge semantics.
4
+ //
5
+ // SLOT: plain-`.ts` hook; forwards the caller's slot to useRenderElement.
6
+ import { splitSlot, S, subSlot } from './internal';
7
+ import {
8
+ useRenderElement,
9
+ type RenderProp,
10
+ type UseRenderElementParameters,
11
+ } from './utils/useRenderElement';
12
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
13
+
14
+ export interface UseRenderParameters<State extends Record<string, unknown>> {
15
+ render?: RenderProp<State>;
16
+ ref?: any;
17
+ state?: State;
18
+ stateAttributesMapping?: StateAttributesMapping<State>;
19
+ props?: Record<string, unknown>;
20
+ enabled?: boolean;
21
+ defaultTagName?: string;
22
+ }
23
+
24
+ export function useRender<State extends Record<string, unknown>>(...args: any[]): any {
25
+ const [user, slotArg] = splitSlot(args);
26
+ const slot = slotArg ?? S('useRender');
27
+ const params = user[0] as UseRenderParameters<State>;
28
+ return useRenderElement(
29
+ params.defaultTagName ?? 'div',
30
+ params as any,
31
+ params as UseRenderElementParameters<State>,
32
+ subSlot(slot, 're'),
33
+ );
34
+ }
35
+
36
+ export namespace useRender {
37
+ export type Parameters<State extends Record<string, unknown>> = UseRenderParameters<State>;
38
+ export type RenderProp<State = Record<string, unknown>> =
39
+ import('./utils/useRenderElement').RenderProp<State>;
40
+ }