@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/radio.ts ADDED
@@ -0,0 +1,401 @@
1
+ // Ported from .base-ui/packages/react/src/radio/ (v1.6.0): root/RadioRoot,
2
+ // root/RadioRootContext, utils/stateAttributesMapping, indicator/RadioIndicator — plus its
3
+ // `index.parts` (the `Radio` namespace).
4
+ //
5
+ // A radio renders a `<span role="radio">` + a hidden `<input type="radio">` (a CompositeItem
6
+ // for roving focus when inside a <RadioGroup>). octane adaptations mirror Switch/Checkbox:
7
+ // native events; the uncontrolled-input pattern (initial checked → attribute, live
8
+ // `input.checked` PROPERTY via the native setter, native click/change dispatch).
9
+ import { createContext, createElement, useContext, useLayoutEffect, useMemo, useRef } from 'octane';
10
+
11
+ import { S, subSlot } from './internal';
12
+ import { useRenderElement, type RenderProp } from './utils/useRenderElement';
13
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
14
+ import { useBaseUiId } from './utils/useBaseUiId';
15
+ import { useButton } from './utils/useButton';
16
+ import { useComposedRefs } from './utils/composeRefs';
17
+ import { useStableCallback } from './utils/useStableCallback';
18
+ import { visuallyHidden, visuallyHiddenInput } from './utils/visuallyHidden';
19
+ import { ownerWindow } from './utils/owner';
20
+ import { NOOP } from './utils/noop';
21
+ import { serializeValue } from './utils/serializeValue';
22
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
23
+ import { fieldValidityMapping, type FieldRootState } from './utils/field/constants';
24
+ import { useFieldRootContext } from './utils/field/FieldRootContext';
25
+ import { useFieldItemContext } from './utils/field/FieldItemContext';
26
+ import { useLabelableContext } from './utils/field/LabelableContext';
27
+ import { useAriaLabelledBy } from './utils/field/useAriaLabelledBy';
28
+ import { useLabelableId } from './utils/field/useLabelableId';
29
+ import { useRadioGroupContext } from './utils/RadioGroupContext';
30
+ import { ACTIVE_COMPOSITE_ITEM } from './utils/composite/keys';
31
+ import { CompositeItem } from './utils/composite/CompositeItem';
32
+ import {
33
+ useTransitionStatus,
34
+ transitionStatusMapping,
35
+ type TransitionStatus,
36
+ } from './utils/useTransitionStatus';
37
+ import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
38
+
39
+ export interface RadioRootState extends FieldRootState {
40
+ checked: boolean;
41
+ readOnly: boolean;
42
+ required: boolean;
43
+ }
44
+
45
+ const stateAttributesMapping: StateAttributesMapping<any> = {
46
+ checked(value: boolean): Record<string, string> {
47
+ if (value) {
48
+ return { 'data-checked': '' };
49
+ }
50
+ return { 'data-unchecked': '' };
51
+ },
52
+ ...(transitionStatusMapping as StateAttributesMapping<any>),
53
+ ...(fieldValidityMapping as StateAttributesMapping<any>),
54
+ };
55
+
56
+ // --- Context -----------------------------------------------------------------
57
+
58
+ const RadioRootContext = createContext<RadioRootState | undefined>(undefined);
59
+
60
+ function useRadioRootContext(): RadioRootState {
61
+ const value = useContext(RadioRootContext);
62
+ if (value === undefined) {
63
+ throw new Error(
64
+ 'Base UI: RadioRootContext is missing. Radio parts must be placed within <Radio.Root>.',
65
+ );
66
+ }
67
+ return value;
68
+ }
69
+
70
+ // --- Root --------------------------------------------------------------------
71
+
72
+ export interface RadioRootProps {
73
+ value: any;
74
+ disabled?: boolean;
75
+ readOnly?: boolean;
76
+ required?: boolean;
77
+ inputRef?: any;
78
+ nativeButton?: boolean;
79
+ id?: string;
80
+ 'aria-labelledby'?: string;
81
+ render?: RenderProp<RadioRootState>;
82
+ className?: string | ((state: RadioRootState) => string | undefined);
83
+ style?: Record<string, any> | ((state: RadioRootState) => Record<string, any> | undefined);
84
+ ref?: any;
85
+ [key: string]: any;
86
+ }
87
+
88
+ function RadioRoot(props: RadioRootProps): any {
89
+ const slot = S('RadioRoot');
90
+ const {
91
+ render,
92
+ className,
93
+ disabled: disabledProp = false,
94
+ readOnly: readOnlyProp = false,
95
+ required: requiredProp = false,
96
+ 'aria-labelledby': ariaLabelledByProp,
97
+ value,
98
+ inputRef: inputRefProp,
99
+ nativeButton = false,
100
+ id: idProp,
101
+ style,
102
+ ref,
103
+ ...elementProps
104
+ } = props;
105
+
106
+ const groupContext = useRadioGroupContext();
107
+ const {
108
+ disabled: disabledGroup,
109
+ readOnly: readOnlyGroup,
110
+ required: requiredGroup,
111
+ form: formGroup,
112
+ checkedValue,
113
+ touched = false,
114
+ validation,
115
+ name,
116
+ } = groupContext ?? {};
117
+ const setCheckedValue = groupContext?.setCheckedValue ?? NOOP;
118
+ const setTouched = groupContext?.setTouched ?? NOOP;
119
+ const registerControlRef = groupContext?.registerControlRef ?? NOOP;
120
+ const registerInputRef = groupContext?.registerInputRef ?? NOOP;
121
+
122
+ const {
123
+ setTouched: setFieldTouched,
124
+ setFilled,
125
+ state: fieldState,
126
+ disabled: fieldDisabled,
127
+ } = useFieldRootContext();
128
+ const fieldItemContext = useFieldItemContext();
129
+ const { labelId, getDescriptionProps } = useLabelableContext();
130
+
131
+ const disabled = fieldDisabled || fieldItemContext.disabled || disabledGroup || disabledProp;
132
+ const readOnly = readOnlyGroup || readOnlyProp;
133
+ const required = requiredGroup || requiredProp;
134
+ const form = formGroup;
135
+
136
+ const checked = groupContext ? checkedValue === value : value === '';
137
+
138
+ const radioRef = useRef<HTMLElement | null>(null, subSlot(slot, 'radioRef'));
139
+ const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
140
+
141
+ // octane: reflect the INITIAL checked as the `checked` ATTRIBUTE (see Switch).
142
+ const initialCheckedRef = useRef(checked, subSlot(slot, 'initialChecked'));
143
+
144
+ const handleControlRef = useStableCallback(
145
+ (element: HTMLElement | null) => {
146
+ if (!element) {
147
+ return;
148
+ }
149
+ registerControlRef(element, disabled);
150
+ },
151
+ subSlot(slot, 'handleControlRef'),
152
+ );
153
+
154
+ const mergedInputRef = useComposedRefs(
155
+ inputRefProp,
156
+ inputRef,
157
+ registerInputRef,
158
+ subSlot(slot, 'mergedInputRef'),
159
+ );
160
+
161
+ useLayoutEffect(
162
+ () => {
163
+ if (inputRef.current?.checked) {
164
+ setFilled(true);
165
+ }
166
+ },
167
+ [setFilled],
168
+ subSlot(slot, 'e:filled'),
169
+ );
170
+
171
+ // octane: drive `input.checked` PROPERTY imperatively (matches React's controlled input).
172
+ useLayoutEffect(
173
+ () => {
174
+ const input = inputRef.current;
175
+ if (!input) {
176
+ return;
177
+ }
178
+ const setNativeChecked = Object.getOwnPropertyDescriptor(
179
+ ownerWindow(input).HTMLInputElement.prototype,
180
+ 'checked',
181
+ )?.set;
182
+ setNativeChecked?.call(input, checked);
183
+ },
184
+ [checked],
185
+ subSlot(slot, 'e:syncChecked'),
186
+ );
187
+
188
+ useLayoutEffect(
189
+ () => {
190
+ if (!inputRef.current) {
191
+ return;
192
+ }
193
+ if (disabled && checked) {
194
+ registerInputRef(null);
195
+ return;
196
+ }
197
+ if (radioRef.current) {
198
+ registerControlRef(radioRef.current, disabled);
199
+ }
200
+ registerInputRef(inputRef.current);
201
+ },
202
+ [checked, disabled, registerControlRef, registerInputRef],
203
+ subSlot(slot, 'e:registerInput'),
204
+ );
205
+
206
+ const id = useBaseUiId(undefined, subSlot(slot, 'id'));
207
+ const inputId = useLabelableId(
208
+ { id: idProp, implicit: false, controlRef: radioRef },
209
+ subSlot(slot, 'inputId'),
210
+ );
211
+ const hiddenInputId = nativeButton ? undefined : inputId;
212
+ const ariaLabelledBy = useAriaLabelledBy(
213
+ ariaLabelledByProp,
214
+ labelId,
215
+ inputRef,
216
+ !nativeButton,
217
+ hiddenInputId,
218
+ subSlot(slot, 'ariaLabelledBy'),
219
+ );
220
+
221
+ const rootProps: Record<string, any> = {
222
+ role: 'radio',
223
+ 'aria-checked': checked,
224
+ 'aria-required': required || undefined,
225
+ 'aria-readonly': readOnly || undefined,
226
+ 'aria-labelledby': ariaLabelledBy,
227
+ [ACTIVE_COMPOSITE_ITEM]: checked ? '' : undefined,
228
+ id: nativeButton ? inputId : id,
229
+ onKeyDown(event: any) {
230
+ if (event.key === 'Enter') {
231
+ event.preventDefault();
232
+ }
233
+ },
234
+ onClick(event: any) {
235
+ if (event.defaultPrevented || disabled || readOnly) {
236
+ return;
237
+ }
238
+ event.preventDefault();
239
+ const input = inputRef.current;
240
+ if (!input) {
241
+ return;
242
+ }
243
+ input.dispatchEvent(
244
+ new (ownerWindow(input).PointerEvent)('click', {
245
+ bubbles: true,
246
+ shiftKey: event.shiftKey,
247
+ ctrlKey: event.ctrlKey,
248
+ altKey: event.altKey,
249
+ metaKey: event.metaKey,
250
+ }),
251
+ );
252
+ },
253
+ onFocus(event: any) {
254
+ if (event.defaultPrevented || disabled || readOnly || !touched) {
255
+ return;
256
+ }
257
+ inputRef.current?.click();
258
+ setTouched(false);
259
+ },
260
+ };
261
+
262
+ const { getButtonProps, buttonRef } = useButton(
263
+ { disabled, native: nativeButton, composite: false },
264
+ subSlot(slot, 'button'),
265
+ );
266
+
267
+ const inputProps: Record<string, any> = {
268
+ type: 'radio',
269
+ ref: mergedInputRef,
270
+ form,
271
+ id: hiddenInputId,
272
+ name,
273
+ tabIndex: -1,
274
+ style: name ? visuallyHiddenInput : visuallyHidden,
275
+ 'aria-hidden': true,
276
+ ...(value !== undefined ? { value: serializeValue(value) } : {}),
277
+ disabled,
278
+ // octane: initial checked → attribute; live value via the property sync effect.
279
+ checked: initialCheckedRef.current || undefined,
280
+ required,
281
+ readOnly,
282
+ onChange(event: any) {
283
+ if (event.defaultPrevented) {
284
+ return;
285
+ }
286
+ if (disabled || readOnly || value === undefined) {
287
+ return;
288
+ }
289
+ const details = createChangeEventDetails(REASONS.none, event);
290
+ setCheckedValue(value, details);
291
+ if (details.isCanceled) {
292
+ return;
293
+ }
294
+ setFieldTouched(true);
295
+ },
296
+ onFocus() {
297
+ radioRef.current?.focus();
298
+ },
299
+ };
300
+
301
+ const state: RadioRootState = useMemo(
302
+ () => ({ ...fieldState, required, disabled, readOnly, checked }),
303
+ [fieldState, disabled, readOnly, checked, required],
304
+ subSlot(slot, 'state'),
305
+ );
306
+
307
+ const isRadioGroup = groupContext !== undefined;
308
+
309
+ const refs = [ref, radioRef, buttonRef, handleControlRef];
310
+ const renderProps = [
311
+ rootProps,
312
+ elementProps,
313
+ getButtonProps,
314
+ getDescriptionProps,
315
+ validation
316
+ ? (validationProps: any) => validation!.getValidationProps(disabled, validationProps)
317
+ : {},
318
+ ];
319
+
320
+ const element = useRenderElement(
321
+ 'span',
322
+ { render, className, style },
323
+ { enabled: !isRadioGroup, state, ref: refs, props: renderProps, stateAttributesMapping },
324
+ subSlot(slot, 're'),
325
+ );
326
+
327
+ const rendered = isRadioGroup
328
+ ? createElement(CompositeItem, {
329
+ tag: 'span',
330
+ render,
331
+ className,
332
+ style,
333
+ state,
334
+ refs,
335
+ props: renderProps,
336
+ stateAttributesMapping,
337
+ })
338
+ : element;
339
+
340
+ return createElement(RadioRootContext.Provider, {
341
+ value: state,
342
+ children: [rendered, createElement('input', inputProps)],
343
+ });
344
+ }
345
+
346
+ // --- Indicator ---------------------------------------------------------------
347
+
348
+ export interface RadioIndicatorState extends RadioRootState {
349
+ transitionStatus: TransitionStatus;
350
+ }
351
+
352
+ function RadioIndicator(props: any): any {
353
+ const slot = S('RadioIndicator');
354
+ const { render, className, style, keepMounted = false, ref, ...elementProps } = props;
355
+
356
+ const rootState = useRadioRootContext();
357
+ const rendered = rootState.checked;
358
+
359
+ const { mounted, transitionStatus, setMounted } = useTransitionStatus(
360
+ rendered,
361
+ subSlot(slot, 'ts'),
362
+ );
363
+
364
+ const state: RadioIndicatorState = { ...rootState, transitionStatus };
365
+ const indicatorRef = useRef<HTMLSpanElement | null>(null, subSlot(slot, 'indRef'));
366
+
367
+ const shouldRender = keepMounted || mounted;
368
+
369
+ const element = useRenderElement(
370
+ 'span',
371
+ { render, className, style },
372
+ { ref: [ref, indicatorRef], state, props: elementProps, stateAttributesMapping },
373
+ subSlot(slot, 're'),
374
+ );
375
+
376
+ useOpenChangeComplete(
377
+ {
378
+ open: rendered,
379
+ ref: indicatorRef,
380
+ onComplete() {
381
+ if (!rendered) {
382
+ setMounted(false);
383
+ }
384
+ },
385
+ },
386
+ subSlot(slot, 'occ'),
387
+ );
388
+
389
+ if (!shouldRender) {
390
+ return null;
391
+ }
392
+
393
+ return element;
394
+ }
395
+
396
+ // --- Namespace (mirrors `export * as Radio`) ---------------------------------
397
+
398
+ export const Radio = {
399
+ Root: RadioRoot,
400
+ Indicator: RadioIndicator,
401
+ };
@@ -0,0 +1,43 @@
1
+ // Ported from .base-ui/packages/react/src/separator/Separator.tsx (v1.6.0).
2
+ // A separator element accessible to screen readers. Renders a `<div>`.
3
+ import { S, subSlot } from './internal';
4
+ import { useRenderElement } from './utils/useRenderElement';
5
+
6
+ type Orientation = 'horizontal' | 'vertical';
7
+
8
+ export interface SeparatorState {
9
+ orientation: Orientation;
10
+ }
11
+
12
+ export interface SeparatorProps {
13
+ orientation?: Orientation;
14
+ className?: string | ((state: SeparatorState) => string | undefined);
15
+ render?: import('./utils/useRenderElement').RenderProp<SeparatorState>;
16
+ ref?: any;
17
+ [key: string]: any;
18
+ }
19
+
20
+ // octane: forwardRef → ref-as-prop. The compiler gives this component its own
21
+ // per-instance scope, so `S('Separator')` resolves to a distinct slot per instance.
22
+ export function Separator(props: SeparatorProps): any {
23
+ const slot = S('Separator');
24
+ const { className, render, orientation = 'horizontal', style, ref, ...elementProps } = props;
25
+
26
+ const state: SeparatorState = { orientation };
27
+
28
+ return useRenderElement(
29
+ 'div',
30
+ { className, render, style },
31
+ {
32
+ state,
33
+ ref,
34
+ props: [{ role: 'separator', 'aria-orientation': orientation }, elementProps],
35
+ },
36
+ subSlot(slot, 're'),
37
+ );
38
+ }
39
+
40
+ export namespace Separator {
41
+ export type Props = SeparatorProps;
42
+ export type State = SeparatorState;
43
+ }