@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.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/package.json +47 -0
- package/src/avatar.ts +308 -0
- package/src/checkbox-group.ts +183 -0
- package/src/checkbox.ts +578 -0
- package/src/field.ts +676 -0
- package/src/fieldset.ts +150 -0
- package/src/form.ts +183 -0
- package/src/index.ts +23 -0
- package/src/input.ts +9 -0
- package/src/internal.ts +42 -0
- package/src/merge-props.ts +2 -0
- package/src/meter.ts +204 -0
- package/src/number-field.ts +978 -0
- package/src/progress.ts +260 -0
- package/src/radio-group.ts +285 -0
- package/src/radio.ts +401 -0
- package/src/separator.ts +43 -0
- package/src/slider.ts +1617 -0
- package/src/switch.ts +374 -0
- package/src/toggle-group.ts +136 -0
- package/src/toggle.ts +145 -0
- package/src/use-render.ts +40 -0
- package/src/utils/CheckboxGroupContext.ts +30 -0
- package/src/utils/CompositeRootContext.ts +27 -0
- package/src/utils/DirectionContext.ts +15 -0
- package/src/utils/RadioGroupContext.ts +27 -0
- package/src/utils/ToggleGroupContext.ts +33 -0
- package/src/utils/addEventListener.ts +13 -0
- package/src/utils/areArraysEqual.ts +11 -0
- package/src/utils/clamp.ts +8 -0
- package/src/utils/composeRefs.ts +53 -0
- package/src/utils/composite/CompositeItem.ts +56 -0
- package/src/utils/composite/CompositeList.ts +190 -0
- package/src/utils/composite/CompositeListContext.ts +23 -0
- package/src/utils/composite/CompositeRoot.ts +132 -0
- package/src/utils/composite/keys.ts +179 -0
- package/src/utils/composite/list-utils.ts +78 -0
- package/src/utils/composite/useCompositeItem.ts +58 -0
- package/src/utils/composite/useCompositeListItem.ts +108 -0
- package/src/utils/composite/useCompositeRoot.ts +346 -0
- package/src/utils/contains.ts +22 -0
- package/src/utils/createChangeEventDetails.ts +81 -0
- package/src/utils/field/FieldItemContext.ts +13 -0
- package/src/utils/field/FieldRootContext.ts +98 -0
- package/src/utils/field/FormContext.ts +37 -0
- package/src/utils/field/LabelableContext.ts +32 -0
- package/src/utils/field/LabelableProvider.ts +108 -0
- package/src/utils/field/constants.ts +80 -0
- package/src/utils/field/getCombinedFieldValidityData.ts +16 -0
- package/src/utils/field/useAriaLabelledBy.ts +86 -0
- package/src/utils/field/useFieldControlRegistration.ts +179 -0
- package/src/utils/field/useFieldValidation.ts +311 -0
- package/src/utils/field/useLabel.ts +95 -0
- package/src/utils/field/useLabelableId.ts +99 -0
- package/src/utils/field/useRegisterFieldControl.ts +41 -0
- package/src/utils/formatNumber.ts +44 -0
- package/src/utils/getDefaultFormSubmitter.ts +19 -0
- package/src/utils/getElementRef.ts +13 -0
- package/src/utils/getStateAttributesProps.ts +34 -0
- package/src/utils/matchesFocusVisible.ts +16 -0
- package/src/utils/mergeObjects.ts +18 -0
- package/src/utils/mergeProps.ts +206 -0
- package/src/utils/noop.ts +4 -0
- package/src/utils/number/constants.ts +4 -0
- package/src/utils/number/parse.ts +227 -0
- package/src/utils/number/types.ts +25 -0
- package/src/utils/number/validate.ts +129 -0
- package/src/utils/owner.ts +9 -0
- package/src/utils/platform.ts +15 -0
- package/src/utils/resolveAriaLabelledBy.ts +11 -0
- package/src/utils/resolveClassName.ts +8 -0
- package/src/utils/resolveRef.ts +10 -0
- package/src/utils/resolveStyle.ts +10 -0
- package/src/utils/serializeValue.ts +15 -0
- package/src/utils/slider/asc.ts +4 -0
- package/src/utils/slider/getMidpoint.ts +14 -0
- package/src/utils/slider/getPushedThumbValues.ts +73 -0
- package/src/utils/slider/getSliderValue.ts +27 -0
- package/src/utils/slider/replaceArrayItemAtIndex.ts +8 -0
- package/src/utils/slider/resolveThumbCollision.ts +178 -0
- package/src/utils/slider/roundValueToStep.ts +22 -0
- package/src/utils/slider/validateMinimumDistance.ts +22 -0
- package/src/utils/slider/valueArrayToPercentages.ts +11 -0
- package/src/utils/stringifyLocale.ts +10 -0
- package/src/utils/useAnimationFrame.ts +59 -0
- package/src/utils/useAnimationsFinished.ts +100 -0
- package/src/utils/useBaseUiId.ts +19 -0
- package/src/utils/useButton.ts +217 -0
- package/src/utils/useCheckboxGroupParent.ts +113 -0
- package/src/utils/useControlled.ts +42 -0
- package/src/utils/useFocusableWhenDisabled.ts +84 -0
- package/src/utils/useForcedRerendering.ts +11 -0
- package/src/utils/useIsHydrating.ts +9 -0
- package/src/utils/useOpenChangeComplete.ts +46 -0
- package/src/utils/usePressAndHold.ts +35 -0
- package/src/utils/useRefWithInit.ts +23 -0
- package/src/utils/useRegisteredLabelId.ts +32 -0
- package/src/utils/useRenderElement.ts +165 -0
- package/src/utils/useStableCallback.ts +26 -0
- package/src/utils/useTimeout.ts +51 -0
- package/src/utils/useTransitionStatus.ts +115 -0
- package/src/utils/useValueAsRef.ts +25 -0
- package/src/utils/useValueChanged.ts +37 -0
- package/src/utils/valueToPercent.ts +4 -0
- package/src/utils/visuallyHidden.ts +24 -0
package/src/fieldset.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/fieldset/ (v1.6.0):
|
|
2
|
+
// index.ts / index.parts.ts (the `Fieldset` namespace),
|
|
3
|
+
// root/FieldsetRoot.tsx, root/FieldsetRootContext.ts, legend/FieldsetLegend.tsx.
|
|
4
|
+
//
|
|
5
|
+
// Groups a shared legend with related controls. `Fieldset.Root` renders a
|
|
6
|
+
// `<fieldset>`; `Fieldset.Legend` renders a `<div>` whose generated id is fed back
|
|
7
|
+
// into the root as `aria-labelledby`. Base UI uses a PLAIN React context here (not the
|
|
8
|
+
// scoped-context factory) — ported as a plain octane createContext. octane is
|
|
9
|
+
// ref-as-prop (React-19 shape), so `forwardRef` collapses to a `ref` prop.
|
|
10
|
+
import {
|
|
11
|
+
createContext,
|
|
12
|
+
createElement,
|
|
13
|
+
useContext,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useState,
|
|
17
|
+
} from 'octane';
|
|
18
|
+
|
|
19
|
+
import { S, splitSlot, subSlot } from './internal';
|
|
20
|
+
import { useRenderElement, type RenderProp } from './utils/useRenderElement';
|
|
21
|
+
import { useBaseUiId } from './utils/useBaseUiId';
|
|
22
|
+
|
|
23
|
+
// --- Context -----------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface FieldsetRootContextValue {
|
|
26
|
+
legendId: string | undefined;
|
|
27
|
+
setLegendId: (id: string | undefined) => void;
|
|
28
|
+
disabled: boolean | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FieldsetRootContext = createContext<FieldsetRootContextValue | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
export function useFieldsetRootContext(optional: true): FieldsetRootContextValue | undefined;
|
|
34
|
+
export function useFieldsetRootContext(optional?: false): FieldsetRootContextValue;
|
|
35
|
+
export function useFieldsetRootContext(optional = false): FieldsetRootContextValue | undefined {
|
|
36
|
+
// octane context reads are not slot-threaded (see runtime `useContext`).
|
|
37
|
+
const context = useContext(FieldsetRootContext);
|
|
38
|
+
if (!context && !optional) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Base UI: FieldsetRootContext is missing. Fieldset parts must be placed within <Fieldset.Root>.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return context;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Root --------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface FieldsetRootState {
|
|
49
|
+
/** Whether the component should ignore user interaction. */
|
|
50
|
+
disabled: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FieldsetRootProps {
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
className?: string | ((state: FieldsetRootState) => string | undefined);
|
|
56
|
+
render?: RenderProp<FieldsetRootState>;
|
|
57
|
+
style?: Record<string, any> | ((state: FieldsetRootState) => Record<string, any> | undefined);
|
|
58
|
+
ref?: any;
|
|
59
|
+
[key: string]: any;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function FieldsetRoot(props: FieldsetRootProps): any {
|
|
63
|
+
const slot = S('FieldsetRoot');
|
|
64
|
+
const { render, className, style, disabled: disabledProp = false, ref, ...elementProps } = props;
|
|
65
|
+
|
|
66
|
+
const [legendId, setLegendId] = useState<string | undefined>(
|
|
67
|
+
undefined,
|
|
68
|
+
subSlot(slot, 'legendId'),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const parentDisabled = useFieldsetRootContext(true)?.disabled;
|
|
72
|
+
const disabled = parentDisabled || disabledProp;
|
|
73
|
+
|
|
74
|
+
const state: FieldsetRootState = { disabled };
|
|
75
|
+
|
|
76
|
+
const element = useRenderElement(
|
|
77
|
+
'fieldset',
|
|
78
|
+
{ render, className, style },
|
|
79
|
+
{
|
|
80
|
+
ref,
|
|
81
|
+
state,
|
|
82
|
+
props: [{ 'aria-labelledby': legendId, disabled }, elementProps],
|
|
83
|
+
},
|
|
84
|
+
subSlot(slot, 're'),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const contextValue: FieldsetRootContextValue = useMemo(
|
|
88
|
+
() => ({ legendId, setLegendId, disabled }),
|
|
89
|
+
[legendId, setLegendId, disabled],
|
|
90
|
+
subSlot(slot, 'ctx'),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return createElement(FieldsetRootContext.Provider, { value: contextValue, children: element });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Legend ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export interface FieldsetLegendState {
|
|
99
|
+
/** Whether the component should ignore user interaction. */
|
|
100
|
+
disabled: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FieldsetLegendProps {
|
|
104
|
+
id?: string;
|
|
105
|
+
className?: string | ((state: FieldsetLegendState) => string | undefined);
|
|
106
|
+
render?: RenderProp<FieldsetLegendState>;
|
|
107
|
+
style?: Record<string, any> | ((state: FieldsetLegendState) => Record<string, any> | undefined);
|
|
108
|
+
ref?: any;
|
|
109
|
+
[key: string]: any;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function FieldsetLegend(props: FieldsetLegendProps): any {
|
|
113
|
+
const slot = S('FieldsetLegend');
|
|
114
|
+
const { render, className, style, id: idProp, ref, ...elementProps } = props;
|
|
115
|
+
|
|
116
|
+
const { disabled, setLegendId } = useFieldsetRootContext();
|
|
117
|
+
|
|
118
|
+
const id = useBaseUiId(idProp, subSlot(slot, 'id'));
|
|
119
|
+
|
|
120
|
+
useLayoutEffect(
|
|
121
|
+
() => {
|
|
122
|
+
setLegendId(id);
|
|
123
|
+
return () => {
|
|
124
|
+
setLegendId(undefined);
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
[setLegendId, id],
|
|
128
|
+
subSlot(slot, 'e:legendId'),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const state: FieldsetLegendState = { disabled: disabled ?? false };
|
|
132
|
+
|
|
133
|
+
return useRenderElement(
|
|
134
|
+
'div',
|
|
135
|
+
{ render, className, style },
|
|
136
|
+
{
|
|
137
|
+
state,
|
|
138
|
+
ref,
|
|
139
|
+
props: [{ id }, elementProps],
|
|
140
|
+
},
|
|
141
|
+
subSlot(slot, 're'),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Namespace (mirrors `export * as Fieldset`) ------------------------------
|
|
146
|
+
|
|
147
|
+
export const Fieldset = {
|
|
148
|
+
Root: FieldsetRoot,
|
|
149
|
+
Legend: FieldsetLegend,
|
|
150
|
+
};
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/form/Form.tsx (v1.6.0). A `<form noValidate>` with
|
|
2
|
+
// consolidated error handling: provides the real FormContext (field registry + errors), runs
|
|
3
|
+
// submit-time validation, and focuses the first invalid control. octane: native events;
|
|
4
|
+
// forwardRef → ref-as-prop.
|
|
5
|
+
import { createElement, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { S, subSlot } from './internal';
|
|
8
|
+
import { useRenderElement, type RenderProp } from './utils/useRenderElement';
|
|
9
|
+
import { useStableCallback } from './utils/useStableCallback';
|
|
10
|
+
import { useValueChanged } from './utils/useValueChanged';
|
|
11
|
+
import { FormContext, type FormContextValue, type Errors } from './utils/field/FormContext';
|
|
12
|
+
import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
|
|
13
|
+
import type { FormValidationMode } from './utils/field/constants';
|
|
14
|
+
|
|
15
|
+
export interface FormProps {
|
|
16
|
+
validationMode?: FormValidationMode;
|
|
17
|
+
errors?: Errors;
|
|
18
|
+
onSubmit?: (event: any) => void;
|
|
19
|
+
onFormSubmit?: (formValues: Record<string, any>, eventDetails: any) => void;
|
|
20
|
+
actionsRef?: any;
|
|
21
|
+
render?: RenderProp<Record<string, never>>;
|
|
22
|
+
className?: string | ((state: Record<string, never>) => string | undefined);
|
|
23
|
+
style?: Record<string, any>;
|
|
24
|
+
ref?: any;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Form(props: FormProps): any {
|
|
29
|
+
const slot = S('Form');
|
|
30
|
+
const {
|
|
31
|
+
render,
|
|
32
|
+
className,
|
|
33
|
+
validationMode = 'onSubmit',
|
|
34
|
+
errors: externalErrors,
|
|
35
|
+
onSubmit,
|
|
36
|
+
onFormSubmit,
|
|
37
|
+
actionsRef,
|
|
38
|
+
style,
|
|
39
|
+
ref,
|
|
40
|
+
...elementProps
|
|
41
|
+
} = props;
|
|
42
|
+
|
|
43
|
+
const formRef = useRef<{ fields: Map<string, any> }>(
|
|
44
|
+
{ fields: new Map() },
|
|
45
|
+
subSlot(slot, 'formRef'),
|
|
46
|
+
);
|
|
47
|
+
const submittedRef = useRef(false, subSlot(slot, 'submitted'));
|
|
48
|
+
const submitAttemptedRef = useRef(false, subSlot(slot, 'attempted'));
|
|
49
|
+
|
|
50
|
+
const focusControl = useStableCallback(
|
|
51
|
+
(control: HTMLElement | null) => {
|
|
52
|
+
if (!control) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
control.focus();
|
|
56
|
+
if (control.tagName === 'INPUT') {
|
|
57
|
+
(control as HTMLInputElement).select();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
subSlot(slot, 'focusControl'),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const [errors, setErrors] = useState<Errors | undefined>(externalErrors, subSlot(slot, 'errors'));
|
|
64
|
+
|
|
65
|
+
useValueChanged(
|
|
66
|
+
externalErrors,
|
|
67
|
+
() => {
|
|
68
|
+
setErrors(externalErrors);
|
|
69
|
+
},
|
|
70
|
+
subSlot(slot, 'errorsChanged'),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
useEffect(
|
|
74
|
+
() => {
|
|
75
|
+
if (!submittedRef.current) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
submittedRef.current = false;
|
|
79
|
+
const invalidFields = Array.from(formRef.current.fields.values()).filter(
|
|
80
|
+
(field: any) => field.validityData.state.valid === false,
|
|
81
|
+
);
|
|
82
|
+
if (invalidFields.length) {
|
|
83
|
+
focusControl(invalidFields[0].controlRef.current);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[errors, focusControl],
|
|
87
|
+
subSlot(slot, 'e:focus'),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const handleImperativeValidate = useStableCallback(
|
|
91
|
+
(fieldName?: string) => {
|
|
92
|
+
const values = Array.from(formRef.current.fields.values());
|
|
93
|
+
if (fieldName) {
|
|
94
|
+
const namedField = values.find((field: any) => field.name === fieldName);
|
|
95
|
+
if (namedField) {
|
|
96
|
+
(namedField as any).validate();
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
values.forEach((field: any) => {
|
|
100
|
+
field.validate();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
subSlot(slot, 'imperativeValidate'),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useImperativeHandle(
|
|
108
|
+
actionsRef,
|
|
109
|
+
() => ({ validate: handleImperativeValidate }),
|
|
110
|
+
[handleImperativeValidate],
|
|
111
|
+
subSlot(slot, 'imperative'),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const element = useRenderElement(
|
|
115
|
+
'form',
|
|
116
|
+
{ render, className, style },
|
|
117
|
+
{
|
|
118
|
+
ref,
|
|
119
|
+
props: [
|
|
120
|
+
{
|
|
121
|
+
noValidate: true,
|
|
122
|
+
onSubmit(event: any) {
|
|
123
|
+
submitAttemptedRef.current = true;
|
|
124
|
+
let values = Array.from(formRef.current.fields.values());
|
|
125
|
+
values.forEach((field: any) => {
|
|
126
|
+
field.validate();
|
|
127
|
+
});
|
|
128
|
+
values = Array.from(formRef.current.fields.values());
|
|
129
|
+
const invalidField = values.find(
|
|
130
|
+
(field: any) => field.validityData.state.valid === false,
|
|
131
|
+
);
|
|
132
|
+
if (invalidField) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
focusControl((invalidField as any).controlRef.current);
|
|
135
|
+
} else {
|
|
136
|
+
submittedRef.current = true;
|
|
137
|
+
onSubmit?.(event);
|
|
138
|
+
if (onFormSubmit) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
const formValues = values.reduce((acc: Record<string, any>, field: any) => {
|
|
141
|
+
if (field.name) {
|
|
142
|
+
acc[field.name] = field.getValue();
|
|
143
|
+
}
|
|
144
|
+
return acc;
|
|
145
|
+
}, {});
|
|
146
|
+
onFormSubmit(formValues, createChangeEventDetails(REASONS.none, event));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
elementProps,
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
subSlot(slot, 're'),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const clearErrors = useStableCallback(
|
|
158
|
+
(name: string | undefined) => {
|
|
159
|
+
if (name && errors && Object.prototype.hasOwnProperty.call(errors, name)) {
|
|
160
|
+
const nextErrors = { ...errors };
|
|
161
|
+
delete nextErrors[name];
|
|
162
|
+
setErrors(nextErrors);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
subSlot(slot, 'clearErrors'),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const contextValue: FormContextValue = useMemo(
|
|
169
|
+
() => ({
|
|
170
|
+
formRef,
|
|
171
|
+
validationMode,
|
|
172
|
+
errors: errors ?? {},
|
|
173
|
+
clearErrors,
|
|
174
|
+
submitAttemptedRef,
|
|
175
|
+
}),
|
|
176
|
+
[formRef, validationMode, errors, clearErrors],
|
|
177
|
+
subSlot(slot, 'ctx'),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return createElement(FormContext.Provider, { value: contextValue, children: element });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export { Form };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @octanejs/base-ui — Base UI (@base-ui/react) ported to the octane
|
|
2
|
+
// renderer. Public API mirrors Base UI: deep-subpath imports
|
|
3
|
+
// (`@octanejs/base-ui/separator`, `@octanejs/base-ui/use-render`, …). This barrel
|
|
4
|
+
// re-exports the public surface for convenience as components land.
|
|
5
|
+
export { Separator } from './separator';
|
|
6
|
+
export { Avatar } from './avatar';
|
|
7
|
+
export { Checkbox } from './checkbox';
|
|
8
|
+
export { CheckboxGroup } from './checkbox-group';
|
|
9
|
+
export { Field } from './field';
|
|
10
|
+
export { Fieldset } from './fieldset';
|
|
11
|
+
export { Form } from './form';
|
|
12
|
+
export { Input } from './input';
|
|
13
|
+
export { Meter } from './meter';
|
|
14
|
+
export { NumberField } from './number-field';
|
|
15
|
+
export { Progress } from './progress';
|
|
16
|
+
export { Radio } from './radio';
|
|
17
|
+
export { RadioGroup } from './radio-group';
|
|
18
|
+
export { Slider } from './slider';
|
|
19
|
+
export { Switch } from './switch';
|
|
20
|
+
export { Toggle } from './toggle';
|
|
21
|
+
export { ToggleGroup } from './toggle-group';
|
|
22
|
+
export { useRender } from './use-render';
|
|
23
|
+
export { mergeProps, mergePropsN, mergeClassNames } from './merge-props';
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/input/Input.tsx (v1.6.0). A native `<input>` that
|
|
2
|
+
// works with Field out of the box — literally `<Field.Control/>`.
|
|
3
|
+
import { createElement } from 'octane';
|
|
4
|
+
|
|
5
|
+
import { Field } from './field';
|
|
6
|
+
|
|
7
|
+
export function Input(props: any): any {
|
|
8
|
+
return createElement(Field.Control, props);
|
|
9
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Slot mechanics for @octanejs/base-ui's plain-`.ts` hooks. octane's compiler injects a
|
|
2
|
+
// per-call-site Symbol slot as the trailing arg of every hook call in a compiled
|
|
3
|
+
// `.tsx`/`.tsrx`. These hook files are published source (consumed from node_modules,
|
|
4
|
+
// where the auto-slotting pass is skipped), so each hook receives the caller's slot as
|
|
5
|
+
// its trailing argument and derives a distinct sub-slot for every base hook it composes.
|
|
6
|
+
// (Same pattern as @octanejs/floating-ui and the other bindings.)
|
|
7
|
+
|
|
8
|
+
// Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook.
|
|
9
|
+
// Memoized: subSlot runs on EVERY hook call every render, and the naive form
|
|
10
|
+
// pays a string concat + global symbol-registry lookup each time. The cache is
|
|
11
|
+
// keyed by the slot symbol itself; the minted value is byte-identical to the
|
|
12
|
+
// uncached Symbol.for result, so identity is preserved across HMR re-evals and
|
|
13
|
+
// the per-package copies of this helper. Key universe is bounded: slots are
|
|
14
|
+
// per-call-site module constants (never minted per render).
|
|
15
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
16
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
|
|
17
|
+
if (slot === undefined) return undefined;
|
|
18
|
+
let byTag = subSlotCache.get(slot);
|
|
19
|
+
if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
|
|
20
|
+
let sym = byTag.get(tag);
|
|
21
|
+
if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
|
|
22
|
+
return sym;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Split the compiler-injected trailing slot off a hook's args. Needed because the public
|
|
26
|
+
// hooks take optional args, so the slot can't be located positionally.
|
|
27
|
+
export function splitSlot(args: any[]): [any[], symbol | undefined] {
|
|
28
|
+
const tail = args[args.length - 1];
|
|
29
|
+
const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
|
|
30
|
+
return [slot !== undefined ? args.slice(0, -1) : args, slot];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A stable per-call-site slot for the binding's plain-`.ts` COMPONENTS (written with
|
|
34
|
+
// createElement, not compiled, so they get no auto-injected slots). A component runs in
|
|
35
|
+
// its OWN per-instance scope (componentSlot), so a globally stable Symbol.for(tag)
|
|
36
|
+
// resolves to a distinct slot per instance.
|
|
37
|
+
const sCache = new Map<string, symbol>();
|
|
38
|
+
export function S(tag: string): symbol {
|
|
39
|
+
let sym = sCache.get(tag);
|
|
40
|
+
if (sym === undefined) sCache.set(tag, (sym = Symbol.for('@octanejs/base-ui:' + tag)));
|
|
41
|
+
return sym;
|
|
42
|
+
}
|
package/src/meter.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/meter/ (v1.6.0): root/MeterRoot,
|
|
2
|
+
// root/MeterRootContext, track/MeterTrack, indicator/MeterIndicator, value/MeterValue,
|
|
3
|
+
// label/MeterLabel — plus its `index.parts` (the `Meter` namespace).
|
|
4
|
+
//
|
|
5
|
+
// A meter visualizes a value within a known range. `Meter.Root` (`role="meter"`) owns
|
|
6
|
+
// the range math and screen-reader aria; `Track`/`Indicator` draw the bar; `Value`/`Label`
|
|
7
|
+
// render text. Base UI uses a PLAIN React context — ported as a plain octane createContext.
|
|
8
|
+
import { createContext, createElement, useContext, useMemo, useState } from 'octane';
|
|
9
|
+
|
|
10
|
+
import { S, subSlot } from './internal';
|
|
11
|
+
import { useRenderElement, type RenderProp } from './utils/useRenderElement';
|
|
12
|
+
import { useRegisteredLabelId } from './utils/useRegisteredLabelId';
|
|
13
|
+
import { valueToPercent } from './utils/valueToPercent';
|
|
14
|
+
import { clamp } from './utils/clamp';
|
|
15
|
+
import { formatNumber } from './utils/formatNumber';
|
|
16
|
+
import { visuallyHidden } from './utils/visuallyHidden';
|
|
17
|
+
|
|
18
|
+
// --- Context -----------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface MeterRootContextValue {
|
|
21
|
+
formattedValue: string;
|
|
22
|
+
max: number;
|
|
23
|
+
min: number;
|
|
24
|
+
/** The value normalized to a `0`–`100` percentage of the range, clamped to those bounds. */
|
|
25
|
+
percentageValue: number;
|
|
26
|
+
setLabelId: (id: string | undefined) => void;
|
|
27
|
+
value: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MeterRootContext = createContext<MeterRootContextValue | undefined>(undefined);
|
|
31
|
+
|
|
32
|
+
function useMeterRootContext(): MeterRootContextValue {
|
|
33
|
+
const context = useContext(MeterRootContext);
|
|
34
|
+
if (context === undefined) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'Base UI: MeterRootContext is missing. Meter parts must be placed within <Meter.Root>.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Root --------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export interface MeterRootProps {
|
|
45
|
+
'aria-valuetext'?: string;
|
|
46
|
+
format?: Intl.NumberFormatOptions;
|
|
47
|
+
getAriaValueText?: (formattedValue: string, value: number) => string;
|
|
48
|
+
locale?: Intl.LocalesArgument;
|
|
49
|
+
max?: number;
|
|
50
|
+
min?: number;
|
|
51
|
+
value: number;
|
|
52
|
+
render?: RenderProp<Record<string, never>>;
|
|
53
|
+
className?: string | ((state: Record<string, never>) => string | undefined);
|
|
54
|
+
style?: Record<string, any>;
|
|
55
|
+
ref?: any;
|
|
56
|
+
[key: string]: any;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function MeterRoot(props: MeterRootProps): any {
|
|
60
|
+
const slot = S('MeterRoot');
|
|
61
|
+
const {
|
|
62
|
+
format,
|
|
63
|
+
getAriaValueText,
|
|
64
|
+
locale,
|
|
65
|
+
max = 100,
|
|
66
|
+
min = 0,
|
|
67
|
+
value: valueProp,
|
|
68
|
+
render,
|
|
69
|
+
className,
|
|
70
|
+
children,
|
|
71
|
+
style,
|
|
72
|
+
ref,
|
|
73
|
+
...elementProps
|
|
74
|
+
} = props;
|
|
75
|
+
|
|
76
|
+
const [labelId, setLabelId] = useState<string | undefined>(undefined, subSlot(slot, 'labelId'));
|
|
77
|
+
|
|
78
|
+
// `clamp` handles infinity, but NaN needs an explicit fallback before normalizing.
|
|
79
|
+
const rawPercentage = valueToPercent(valueProp, min, max);
|
|
80
|
+
const percentageValue = clamp(Number.isNaN(rawPercentage) ? 0 : rawPercentage, 0, 100);
|
|
81
|
+
const clampedValue = clamp(Number.isNaN(valueProp) ? min : valueProp, min, max);
|
|
82
|
+
|
|
83
|
+
// Without an explicit `format`, the value shows as its position within the range so
|
|
84
|
+
// the text stays in sync with the indicator fill for any `min`/`max`.
|
|
85
|
+
const formattedValue = format
|
|
86
|
+
? formatNumber(valueProp, locale, format)
|
|
87
|
+
: formatNumber(percentageValue / 100, locale, { style: 'percent' });
|
|
88
|
+
|
|
89
|
+
let ariaValuetext = formattedValue;
|
|
90
|
+
if (getAriaValueText) {
|
|
91
|
+
ariaValuetext = getAriaValueText(formattedValue, valueProp);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const defaultProps = {
|
|
95
|
+
'aria-labelledby': labelId,
|
|
96
|
+
'aria-valuemax': max,
|
|
97
|
+
'aria-valuemin': min,
|
|
98
|
+
'aria-valuenow': clampedValue,
|
|
99
|
+
'aria-valuetext': ariaValuetext,
|
|
100
|
+
role: 'meter',
|
|
101
|
+
children: [
|
|
102
|
+
children,
|
|
103
|
+
// force NVDA to read the label (mui/base-ui#4184)
|
|
104
|
+
createElement('span', { role: 'presentation', style: visuallyHidden, children: 'x' }),
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const contextValue: MeterRootContextValue = useMemo(
|
|
109
|
+
() => ({ formattedValue, max, min, percentageValue, setLabelId, value: valueProp }),
|
|
110
|
+
[formattedValue, max, min, percentageValue, setLabelId, valueProp],
|
|
111
|
+
subSlot(slot, 'ctx'),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const element = useRenderElement(
|
|
115
|
+
'div',
|
|
116
|
+
{ render, className, style },
|
|
117
|
+
{ ref, props: [defaultProps, elementProps] },
|
|
118
|
+
subSlot(slot, 're'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return createElement(MeterRootContext.Provider, { value: contextValue, children: element });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Track -------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function MeterTrack(props: any): any {
|
|
127
|
+
const slot = S('MeterTrack');
|
|
128
|
+
const { render, className, style, ref, ...elementProps } = props;
|
|
129
|
+
return useRenderElement(
|
|
130
|
+
'div',
|
|
131
|
+
{ render, className, style },
|
|
132
|
+
{ ref, props: elementProps },
|
|
133
|
+
subSlot(slot, 're'),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Indicator ---------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function MeterIndicator(props: any): any {
|
|
140
|
+
const slot = S('MeterIndicator');
|
|
141
|
+
const { render, className, style, ref, ...elementProps } = props;
|
|
142
|
+
const { percentageValue } = useMeterRootContext();
|
|
143
|
+
return useRenderElement(
|
|
144
|
+
'div',
|
|
145
|
+
{ render, className, style },
|
|
146
|
+
{
|
|
147
|
+
ref,
|
|
148
|
+
props: [
|
|
149
|
+
{ style: { insetInlineStart: 0, height: 'inherit', width: `${percentageValue}%` } },
|
|
150
|
+
elementProps,
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
subSlot(slot, 're'),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Value -------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
function MeterValue(props: any): any {
|
|
160
|
+
const slot = S('MeterValue');
|
|
161
|
+
const { className, render, children, style, ref, ...elementProps } = props;
|
|
162
|
+
const { value, formattedValue } = useMeterRootContext();
|
|
163
|
+
return useRenderElement(
|
|
164
|
+
'span',
|
|
165
|
+
{ render, className, style },
|
|
166
|
+
{
|
|
167
|
+
ref,
|
|
168
|
+
props: [
|
|
169
|
+
{
|
|
170
|
+
'aria-hidden': true,
|
|
171
|
+
children:
|
|
172
|
+
typeof children === 'function' ? children(formattedValue, value) : formattedValue,
|
|
173
|
+
},
|
|
174
|
+
elementProps,
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
subSlot(slot, 're'),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Label -------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
function MeterLabel(props: any): any {
|
|
184
|
+
const slot = S('MeterLabel');
|
|
185
|
+
const { render, className, style, id: idProp, ref, ...elementProps } = props;
|
|
186
|
+
const { setLabelId } = useMeterRootContext();
|
|
187
|
+
const id = useRegisteredLabelId(idProp, setLabelId, subSlot(slot, 'labelId'));
|
|
188
|
+
return useRenderElement(
|
|
189
|
+
'span',
|
|
190
|
+
{ render, className, style },
|
|
191
|
+
{ ref, props: [{ id, role: 'presentation' }, elementProps] },
|
|
192
|
+
subSlot(slot, 're'),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Namespace (mirrors `export * as Meter`) ---------------------------------
|
|
197
|
+
|
|
198
|
+
export const Meter = {
|
|
199
|
+
Root: MeterRoot,
|
|
200
|
+
Track: MeterTrack,
|
|
201
|
+
Indicator: MeterIndicator,
|
|
202
|
+
Value: MeterValue,
|
|
203
|
+
Label: MeterLabel,
|
|
204
|
+
};
|