@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/field/root/useFieldValidation.ts. The field's
|
|
2
|
+
// validation state machine: runs native constraint validation + a custom `validate` fn on
|
|
3
|
+
// commit/change (debounced), tracks a registry of inputs (for checkbox groups), and produces
|
|
4
|
+
// `getValidationProps` (aria-invalid + aria-describedby). Framework-agnostic logic; octane
|
|
5
|
+
// hooks are slot-threaded.
|
|
6
|
+
//
|
|
7
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
8
|
+
import { useCallback, useMemo, useRef } from 'octane';
|
|
9
|
+
|
|
10
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
11
|
+
import { mergeProps } from '../mergeProps';
|
|
12
|
+
import { useTimeout } from '../useTimeout';
|
|
13
|
+
import { useRefWithInit } from '../useRefWithInit';
|
|
14
|
+
import { useStableCallback } from '../useStableCallback';
|
|
15
|
+
import { DEFAULT_VALIDITY_STATE, type FieldValidityData, type FieldRootState } from './constants';
|
|
16
|
+
import { getCombinedFieldValidityData } from './getCombinedFieldValidityData';
|
|
17
|
+
import { useFormContext } from './FormContext';
|
|
18
|
+
import { useLabelableContext } from './LabelableContext';
|
|
19
|
+
|
|
20
|
+
const validityKeys = Object.keys(DEFAULT_VALIDITY_STATE) as Array<keyof ValidityState>;
|
|
21
|
+
|
|
22
|
+
function isOnlyValueMissing(state: Record<string, boolean> | undefined): boolean {
|
|
23
|
+
if (!state || state.valid || !state.valueMissing) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
let onlyValueMissing = false;
|
|
27
|
+
for (const key of validityKeys) {
|
|
28
|
+
if (key === 'valid') {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (key === 'valueMissing') {
|
|
32
|
+
onlyValueMissing = state[key];
|
|
33
|
+
} else if (state[key]) {
|
|
34
|
+
onlyValueMissing = false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return onlyValueMissing;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findRepresentativeInput(inputs: Set<HTMLInputElement>): HTMLInputElement | null {
|
|
41
|
+
let fallback: HTMLInputElement | null = null;
|
|
42
|
+
for (const input of inputs) {
|
|
43
|
+
if (input.disabled) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!input.validity.valid) {
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
fallback ??= input;
|
|
50
|
+
}
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearCustomValidity(element: HTMLInputElement, inputs: Set<HTMLInputElement>): void {
|
|
55
|
+
let didClearElement = false;
|
|
56
|
+
for (const input of inputs) {
|
|
57
|
+
input.setCustomValidity('');
|
|
58
|
+
didClearElement ||= input === element;
|
|
59
|
+
}
|
|
60
|
+
if (!didClearElement) {
|
|
61
|
+
element.setCustomValidity('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UseFieldValidationParameters {
|
|
66
|
+
setValidityData: (data: FieldValidityData) => void;
|
|
67
|
+
validate: (value: unknown, formValues: Record<string, unknown>) => any;
|
|
68
|
+
validityData: FieldValidityData;
|
|
69
|
+
validationDebounceTime: number;
|
|
70
|
+
invalid: boolean;
|
|
71
|
+
markedDirtyRef: { current: boolean };
|
|
72
|
+
state: FieldRootState;
|
|
73
|
+
shouldValidateOnChange: () => boolean;
|
|
74
|
+
getRegisteredFieldId: () => string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useFieldValidation(...args: any[]): any {
|
|
78
|
+
const [user, slotArg] = splitSlot(args);
|
|
79
|
+
const slot = slotArg ?? S('useFieldValidation');
|
|
80
|
+
const params = user[0] as UseFieldValidationParameters;
|
|
81
|
+
const { formRef } = useFormContext();
|
|
82
|
+
const {
|
|
83
|
+
setValidityData,
|
|
84
|
+
validate,
|
|
85
|
+
validityData,
|
|
86
|
+
validationDebounceTime,
|
|
87
|
+
invalid,
|
|
88
|
+
markedDirtyRef,
|
|
89
|
+
state,
|
|
90
|
+
shouldValidateOnChange,
|
|
91
|
+
getRegisteredFieldId,
|
|
92
|
+
} = params;
|
|
93
|
+
|
|
94
|
+
const { controlId, getDescriptionProps } = useLabelableContext();
|
|
95
|
+
|
|
96
|
+
const timeout = useTimeout(subSlot(slot, 'timeout'));
|
|
97
|
+
const inputRef = useRef<HTMLInputElement | null>(null, subSlot(slot, 'inputRef'));
|
|
98
|
+
const registeredInputs = useRefWithInit<Set<HTMLInputElement>>(
|
|
99
|
+
() => new Set(),
|
|
100
|
+
subSlot(slot, 'registered'),
|
|
101
|
+
).current;
|
|
102
|
+
const validationCommitIdRef = useRef(0, subSlot(slot, 'commitId'));
|
|
103
|
+
|
|
104
|
+
const registerInput = useCallback(
|
|
105
|
+
(element: HTMLInputElement | null) => {
|
|
106
|
+
if (!element) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
registeredInputs.add(element);
|
|
110
|
+
return () => {
|
|
111
|
+
registeredInputs.delete(element);
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
[registeredInputs],
|
|
115
|
+
subSlot(slot, 'registerInput'),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const commit = useStableCallback(
|
|
119
|
+
async (value: unknown, revalidate = false) => {
|
|
120
|
+
const element = findRepresentativeInput(registeredInputs) ?? inputRef.current;
|
|
121
|
+
if (!element) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
validationCommitIdRef.current += 1;
|
|
126
|
+
const validationCommitId = validationCommitIdRef.current;
|
|
127
|
+
|
|
128
|
+
function updateRegisteredFieldValidity(
|
|
129
|
+
nextValidityData: FieldValidityData,
|
|
130
|
+
externalInvalid = invalid,
|
|
131
|
+
): void {
|
|
132
|
+
const fieldId = getRegisteredFieldId() ?? controlId;
|
|
133
|
+
if (fieldId == null) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const currentFieldData = formRef.current.fields.get(fieldId);
|
|
137
|
+
if (!currentFieldData) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const validityDataWithFormErrors = getCombinedFieldValidityData(
|
|
141
|
+
nextValidityData,
|
|
142
|
+
externalInvalid,
|
|
143
|
+
);
|
|
144
|
+
formRef.current.fields.set(fieldId, {
|
|
145
|
+
...currentFieldData,
|
|
146
|
+
validityData: validityDataWithFormErrors,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (revalidate) {
|
|
151
|
+
if (state.valid !== false) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const currentNativeValidity = element.validity;
|
|
155
|
+
if (!currentNativeValidity.valueMissing) {
|
|
156
|
+
const nextValidityData: FieldValidityData = {
|
|
157
|
+
value,
|
|
158
|
+
state: { ...DEFAULT_VALIDITY_STATE, valid: true },
|
|
159
|
+
error: '',
|
|
160
|
+
errors: [],
|
|
161
|
+
initialValue: validityData.initialValue,
|
|
162
|
+
};
|
|
163
|
+
clearCustomValidity(element, registeredInputs);
|
|
164
|
+
updateRegisteredFieldValidity(nextValidityData, false);
|
|
165
|
+
setValidityData(nextValidityData);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const currentNativeValidityObject = validityKeys.reduce(
|
|
169
|
+
(acc, key) => {
|
|
170
|
+
acc[key] = currentNativeValidity[key];
|
|
171
|
+
return acc;
|
|
172
|
+
},
|
|
173
|
+
{} as Record<string, boolean>,
|
|
174
|
+
);
|
|
175
|
+
if (
|
|
176
|
+
!currentNativeValidityObject.valid &&
|
|
177
|
+
!isOnlyValueMissing(currentNativeValidityObject)
|
|
178
|
+
) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getState(el: HTMLInputElement): Record<string, boolean> {
|
|
184
|
+
const computedState = validityKeys.reduce(
|
|
185
|
+
(acc, key) => {
|
|
186
|
+
acc[key] = el.validity[key];
|
|
187
|
+
return acc;
|
|
188
|
+
},
|
|
189
|
+
{} as Record<string, boolean>,
|
|
190
|
+
);
|
|
191
|
+
let hasOnlyValueMissingError = false;
|
|
192
|
+
for (const key of validityKeys) {
|
|
193
|
+
if (key === 'valid') {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (key === 'valueMissing' && computedState[key]) {
|
|
197
|
+
hasOnlyValueMissingError = true;
|
|
198
|
+
} else if (computedState[key]) {
|
|
199
|
+
return computedState;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (hasOnlyValueMissingError && !markedDirtyRef.current) {
|
|
203
|
+
computedState.valid = true;
|
|
204
|
+
computedState.valueMissing = false;
|
|
205
|
+
}
|
|
206
|
+
return computedState;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
timeout.clear();
|
|
210
|
+
|
|
211
|
+
let result: null | string | string[] = null;
|
|
212
|
+
let validationErrors: string[] = [];
|
|
213
|
+
const nextState = getState(element);
|
|
214
|
+
let defaultValidationMessage: string | undefined;
|
|
215
|
+
const isValidatingOnChange = shouldValidateOnChange();
|
|
216
|
+
|
|
217
|
+
if (element.validationMessage && !isValidatingOnChange) {
|
|
218
|
+
defaultValidationMessage = element.validationMessage;
|
|
219
|
+
validationErrors = [element.validationMessage];
|
|
220
|
+
} else {
|
|
221
|
+
const formValues = Array.from(formRef.current.fields.values()).reduce(
|
|
222
|
+
(acc: Record<string, unknown>, field: any) => {
|
|
223
|
+
if (field.name) {
|
|
224
|
+
acc[field.name] = field.getValue();
|
|
225
|
+
}
|
|
226
|
+
return acc;
|
|
227
|
+
},
|
|
228
|
+
{} as Record<string, unknown>,
|
|
229
|
+
);
|
|
230
|
+
const resultOrPromise = validate(value, formValues);
|
|
231
|
+
if (
|
|
232
|
+
typeof resultOrPromise === 'object' &&
|
|
233
|
+
resultOrPromise !== null &&
|
|
234
|
+
'then' in resultOrPromise
|
|
235
|
+
) {
|
|
236
|
+
result = await resultOrPromise;
|
|
237
|
+
if (validationCommitId !== validationCommitIdRef.current) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
result = resultOrPromise;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (result !== null) {
|
|
245
|
+
nextState.valid = false;
|
|
246
|
+
nextState.customError = true;
|
|
247
|
+
if (Array.isArray(result)) {
|
|
248
|
+
validationErrors = result;
|
|
249
|
+
element.setCustomValidity(result.join('\n'));
|
|
250
|
+
} else if (result) {
|
|
251
|
+
validationErrors = [result];
|
|
252
|
+
element.setCustomValidity(result);
|
|
253
|
+
}
|
|
254
|
+
} else if (isValidatingOnChange) {
|
|
255
|
+
clearCustomValidity(element, registeredInputs);
|
|
256
|
+
nextState.customError = false;
|
|
257
|
+
if (element.validationMessage) {
|
|
258
|
+
defaultValidationMessage = element.validationMessage;
|
|
259
|
+
validationErrors = [element.validationMessage];
|
|
260
|
+
} else if (element.validity.valid && !nextState.valid) {
|
|
261
|
+
nextState.valid = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const nextValidityData: FieldValidityData = {
|
|
267
|
+
value,
|
|
268
|
+
state: nextState as any,
|
|
269
|
+
error: defaultValidationMessage ?? (Array.isArray(result) ? result[0] : (result ?? '')),
|
|
270
|
+
errors: validationErrors,
|
|
271
|
+
initialValue: validityData.initialValue,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
updateRegisteredFieldValidity(nextValidityData);
|
|
275
|
+
setValidityData(nextValidityData);
|
|
276
|
+
},
|
|
277
|
+
subSlot(slot, 'commit'),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const change = useStableCallback(
|
|
281
|
+
(value: unknown) => {
|
|
282
|
+
timeout.clear();
|
|
283
|
+
const validateOnChange = shouldValidateOnChange();
|
|
284
|
+
if (validateOnChange && value !== '' && validationDebounceTime) {
|
|
285
|
+
validationCommitIdRef.current += 1;
|
|
286
|
+
timeout.start(validationDebounceTime, () => {
|
|
287
|
+
commit(value);
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
commit(value, !validateOnChange);
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
subSlot(slot, 'change'),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const getValidationProps = useCallback(
|
|
297
|
+
(disabled: boolean, externalProps: Record<string, any> = {}) =>
|
|
298
|
+
mergeProps(
|
|
299
|
+
getDescriptionProps(externalProps),
|
|
300
|
+
state.valid === false && !state.disabled && !disabled ? { 'aria-invalid': true } : {},
|
|
301
|
+
),
|
|
302
|
+
[getDescriptionProps, state.disabled, state.valid],
|
|
303
|
+
subSlot(slot, 'getValidationProps'),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
return useMemo(
|
|
307
|
+
() => ({ getValidationProps, inputRef, registerInput, commit, change }),
|
|
308
|
+
[getValidationProps, registerInput, commit, change],
|
|
309
|
+
subSlot(slot, 'return'),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/internals/labelable-provider/useLabel.ts. Produces
|
|
2
|
+
// a native <label>'s props (`htmlFor` = the control id, `onMouseDown` to avoid text-selection /
|
|
3
|
+
// focus the control) or a non-native label's click/pointer props. octane: native events.
|
|
4
|
+
//
|
|
5
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
6
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
7
|
+
import { getTarget } from '../composite/list-utils';
|
|
8
|
+
import { ownerDocument } from '../owner';
|
|
9
|
+
import { useRegisteredLabelId } from '../useRegisteredLabelId';
|
|
10
|
+
import { useStableCallback } from '../useStableCallback';
|
|
11
|
+
import { useLabelableContext } from './LabelableContext';
|
|
12
|
+
|
|
13
|
+
export interface UseLabelParameters {
|
|
14
|
+
id?: string;
|
|
15
|
+
fallbackControlId?: string | null;
|
|
16
|
+
native?: boolean;
|
|
17
|
+
setLabelId?: (nextLabelId: string | undefined) => void;
|
|
18
|
+
focusControl?: (event: any, controlId: string | null | undefined) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isHTMLElement(el: unknown): el is HTMLElement {
|
|
22
|
+
return el != null && el instanceof HTMLElement;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function focusElementWithVisible(element: HTMLElement): void {
|
|
26
|
+
(element as any).focus({ focusVisible: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useLabel(...args: any[]): Record<string, any> {
|
|
30
|
+
const [user, slotArg] = splitSlot(args);
|
|
31
|
+
const slot = slotArg ?? S('useLabel');
|
|
32
|
+
const {
|
|
33
|
+
id: idProp,
|
|
34
|
+
fallbackControlId,
|
|
35
|
+
native = false,
|
|
36
|
+
setLabelId: setLabelIdProp,
|
|
37
|
+
focusControl: focusControlProp,
|
|
38
|
+
} = (user[0] as UseLabelParameters) ?? {};
|
|
39
|
+
|
|
40
|
+
const { controlId: contextControlId, setLabelId: setContextLabelId } = useLabelableContext();
|
|
41
|
+
|
|
42
|
+
const syncLabelId = useStableCallback(
|
|
43
|
+
(nextLabelId: string | undefined) => {
|
|
44
|
+
setContextLabelId(nextLabelId);
|
|
45
|
+
setLabelIdProp?.(nextLabelId);
|
|
46
|
+
},
|
|
47
|
+
subSlot(slot, 'sync'),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const id = useRegisteredLabelId(idProp, syncLabelId, subSlot(slot, 'id'));
|
|
51
|
+
|
|
52
|
+
const resolvedControlId = contextControlId ?? fallbackControlId;
|
|
53
|
+
|
|
54
|
+
function focusControl(event: any): void {
|
|
55
|
+
if (focusControlProp) {
|
|
56
|
+
focusControlProp(event, resolvedControlId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!resolvedControlId) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const controlElement = ownerDocument(event.currentTarget).getElementById(resolvedControlId);
|
|
63
|
+
if (isHTMLElement(controlElement)) {
|
|
64
|
+
focusElementWithVisible(controlElement);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleInteraction(event: any): void {
|
|
69
|
+
const target = getTarget(event) as HTMLElement | null;
|
|
70
|
+
if (target?.closest('button,input,select,textarea')) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!event.defaultPrevented && event.detail > 1) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
}
|
|
76
|
+
if (native) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
focusControl(event);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return native
|
|
83
|
+
? {
|
|
84
|
+
id,
|
|
85
|
+
htmlFor: resolvedControlId ?? undefined,
|
|
86
|
+
onMouseDown: handleInteraction,
|
|
87
|
+
}
|
|
88
|
+
: {
|
|
89
|
+
id,
|
|
90
|
+
onClick: handleInteraction,
|
|
91
|
+
onPointerDown(event: any) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/internals/labelable-provider/useLabelableId.ts.
|
|
2
|
+
// Resolves a control's id and registers it with the LabelableProvider (noop when standalone —
|
|
3
|
+
// `registerControlId === NOOP` — where it simply returns a generated id).
|
|
4
|
+
//
|
|
5
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
6
|
+
import { useEffect, useLayoutEffect, useRef } from 'octane';
|
|
7
|
+
|
|
8
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
9
|
+
import { NOOP } from '../noop';
|
|
10
|
+
import { useBaseUiId } from '../useBaseUiId';
|
|
11
|
+
import { useRefWithInit } from '../useRefWithInit';
|
|
12
|
+
import { useStableCallback } from '../useStableCallback';
|
|
13
|
+
import { useLabelableContext } from './LabelableContext';
|
|
14
|
+
|
|
15
|
+
export interface UseLabelableIdParameters {
|
|
16
|
+
id?: string;
|
|
17
|
+
implicit?: boolean;
|
|
18
|
+
controlRef?: { current: HTMLElement | null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isElement(el: unknown): el is Element {
|
|
22
|
+
return el != null && el instanceof Element;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useLabelableId(...args: any[]): string | undefined {
|
|
26
|
+
const [user, slotArg] = splitSlot(args);
|
|
27
|
+
const slot = slotArg ?? S('useLabelableId');
|
|
28
|
+
const { id, implicit = false, controlRef } = (user[0] as UseLabelableIdParameters) ?? {};
|
|
29
|
+
|
|
30
|
+
const { controlId, registerControlId } = useLabelableContext();
|
|
31
|
+
|
|
32
|
+
const defaultId = useBaseUiId(id, subSlot(slot, 'def'));
|
|
33
|
+
const controlIdForEffect = implicit ? controlId : undefined;
|
|
34
|
+
|
|
35
|
+
const controlSourceRef = useRefWithInit<symbol>(
|
|
36
|
+
() => Symbol('labelable-control'),
|
|
37
|
+
subSlot(slot, 'src'),
|
|
38
|
+
);
|
|
39
|
+
const hasRegisteredRef = useRef(false, subSlot(slot, 'has'));
|
|
40
|
+
const hadExplicitIdRef = useRef(id != null, subSlot(slot, 'had'));
|
|
41
|
+
|
|
42
|
+
const unregisterControlId = useStableCallback(
|
|
43
|
+
() => {
|
|
44
|
+
if (!hasRegisteredRef.current || registerControlId === NOOP) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
hasRegisteredRef.current = false;
|
|
48
|
+
registerControlId(controlSourceRef.current, undefined);
|
|
49
|
+
},
|
|
50
|
+
subSlot(slot, 'unreg'),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
useLayoutEffect(
|
|
54
|
+
() => {
|
|
55
|
+
if (registerControlId === NOOP) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
let nextId: string | null | undefined;
|
|
59
|
+
if (implicit) {
|
|
60
|
+
const elem = controlRef?.current;
|
|
61
|
+
if (isElement(elem) && elem.closest('label') != null) {
|
|
62
|
+
nextId = id ?? null;
|
|
63
|
+
} else {
|
|
64
|
+
nextId = controlIdForEffect ?? defaultId;
|
|
65
|
+
}
|
|
66
|
+
} else if (id != null) {
|
|
67
|
+
hadExplicitIdRef.current = true;
|
|
68
|
+
nextId = id;
|
|
69
|
+
} else if (hadExplicitIdRef.current) {
|
|
70
|
+
nextId = defaultId;
|
|
71
|
+
} else {
|
|
72
|
+
unregisterControlId();
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (nextId === undefined) {
|
|
76
|
+
unregisterControlId();
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
hasRegisteredRef.current = true;
|
|
80
|
+
registerControlId(controlSourceRef.current, nextId);
|
|
81
|
+
return undefined;
|
|
82
|
+
},
|
|
83
|
+
[
|
|
84
|
+
id,
|
|
85
|
+
controlRef,
|
|
86
|
+
controlIdForEffect,
|
|
87
|
+
registerControlId,
|
|
88
|
+
implicit,
|
|
89
|
+
defaultId,
|
|
90
|
+
controlSourceRef,
|
|
91
|
+
unregisterControlId,
|
|
92
|
+
],
|
|
93
|
+
subSlot(slot, 'e:reg'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
useEffect(() => unregisterControlId, [unregisterControlId], subSlot(slot, 'e:cleanup'));
|
|
97
|
+
|
|
98
|
+
return controlId ?? defaultId;
|
|
99
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/internals/field-register-control/useRegisterFieldControl.ts.
|
|
2
|
+
// Registers a control with the enclosing <Field.Root> (noop when standalone).
|
|
3
|
+
//
|
|
4
|
+
// SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
|
|
5
|
+
import { useLayoutEffect, useRef } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { S, splitSlot, subSlot } from '../../internal';
|
|
8
|
+
import { useFieldRootContext } from './FieldRootContext';
|
|
9
|
+
|
|
10
|
+
export function useRegisterFieldControl(...args: any[]): void {
|
|
11
|
+
const [user, slotArg] = splitSlot(args);
|
|
12
|
+
const slot = slotArg ?? S('useRegisterFieldControl');
|
|
13
|
+
const controlRef = user[0];
|
|
14
|
+
const id = user[1];
|
|
15
|
+
const value = user[2];
|
|
16
|
+
const getFormValueOverride = user[3];
|
|
17
|
+
const enabled = (user[4] as boolean | undefined) ?? true;
|
|
18
|
+
const name = user[5];
|
|
19
|
+
|
|
20
|
+
const { registerFieldControl } = useFieldRootContext();
|
|
21
|
+
const sourceRef = useRef<symbol | null>(null, subSlot(slot, 'src'));
|
|
22
|
+
if (!sourceRef.current) {
|
|
23
|
+
sourceRef.current = Symbol();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
useLayoutEffect(
|
|
27
|
+
() => {
|
|
28
|
+
const source = sourceRef.current;
|
|
29
|
+
if (!source || !enabled) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const registration = { controlRef, getValue: getFormValueOverride, id, name, value };
|
|
33
|
+
registerFieldControl(source, registration);
|
|
34
|
+
return () => {
|
|
35
|
+
registerFieldControl(source, undefined);
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
[controlRef, enabled, getFormValueOverride, id, name, registerFieldControl, value],
|
|
39
|
+
subSlot(slot, 'e:reg'),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/utils/formatNumber.ts. Pure `Intl.NumberFormat`
|
|
2
|
+
// wrapper with a memoized formatter cache — framework-agnostic, so it ports verbatim.
|
|
3
|
+
import { stringifyLocale } from './stringifyLocale';
|
|
4
|
+
|
|
5
|
+
export const cache = new Map<string, Intl.NumberFormat>();
|
|
6
|
+
|
|
7
|
+
export function getFormatter(
|
|
8
|
+
locale?: Intl.LocalesArgument,
|
|
9
|
+
options?: Intl.NumberFormatOptions,
|
|
10
|
+
): Intl.NumberFormat {
|
|
11
|
+
const optionsString = JSON.stringify({ locale: stringifyLocale(locale), options });
|
|
12
|
+
const cachedFormatter = cache.get(optionsString);
|
|
13
|
+
if (cachedFormatter) {
|
|
14
|
+
return cachedFormatter;
|
|
15
|
+
}
|
|
16
|
+
const formatter = new Intl.NumberFormat(locale, options);
|
|
17
|
+
cache.set(optionsString, formatter);
|
|
18
|
+
return formatter;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatNumber(
|
|
22
|
+
value: number | null,
|
|
23
|
+
locale?: Intl.LocalesArgument,
|
|
24
|
+
options?: Intl.NumberFormatOptions,
|
|
25
|
+
): string {
|
|
26
|
+
if (value == null) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
return getFormatter(locale, options).format(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatNumberValue(
|
|
33
|
+
value: number | null,
|
|
34
|
+
locale?: Intl.LocalesArgument,
|
|
35
|
+
format?: Intl.NumberFormatOptions,
|
|
36
|
+
): string {
|
|
37
|
+
if (value == null) {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
if (!format) {
|
|
41
|
+
return formatNumber(value / 100, locale, { style: 'percent' });
|
|
42
|
+
}
|
|
43
|
+
return formatNumber(value, locale, format);
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ported verbatim from .base-ui/packages/utils/src/getDefaultFormSubmitter.ts. Returns the
|
|
2
|
+
// button a browser would use for implicit (Enter-key) form submission.
|
|
3
|
+
export type DefaultFormSubmitter = HTMLButtonElement | HTMLInputElement;
|
|
4
|
+
|
|
5
|
+
export function getDefaultFormSubmitter(form: HTMLFormElement | null): DefaultFormSubmitter | null {
|
|
6
|
+
if (!form) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
for (const candidate of Array.from(form.elements)) {
|
|
10
|
+
const tagName = candidate.tagName;
|
|
11
|
+
if (tagName === 'BUTTON' || tagName === 'INPUT') {
|
|
12
|
+
const button = candidate as HTMLButtonElement | HTMLInputElement;
|
|
13
|
+
if (button.type === 'submit') {
|
|
14
|
+
return button;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// octane analogue of @base-ui/utils/getReactElementRef
|
|
2
|
+
// (.base-ui/packages/utils/src/getReactElementRef.ts). octane is ref-as-prop
|
|
3
|
+
// (React-19 shape), so a descriptor's ref lives in `props.ref`. Returns null for
|
|
4
|
+
// a non-element (function render prop, text, null).
|
|
5
|
+
import { isValidElement } from 'octane';
|
|
6
|
+
|
|
7
|
+
export function getElementRef(element: unknown): any {
|
|
8
|
+
if (!isValidElement(element)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const props = (element as any).props as { ref?: any } | undefined;
|
|
12
|
+
return props?.ref ?? null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/utils/getStateAttributesProps.ts.
|
|
2
|
+
// Turns a component's `state` object into `data-*` attributes: `true` → `data-key=""`,
|
|
3
|
+
// any other truthy value → `data-key="<value>"`, falsy → omitted. A `customMapping`
|
|
4
|
+
// entry overrides the default for a given state key.
|
|
5
|
+
export type StateAttributesMapping<State> = {
|
|
6
|
+
[Property in keyof State]?: (state: State[Property]) => Record<string, string> | null;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function getStateAttributesProps<State extends Record<string, any>>(
|
|
10
|
+
state: State,
|
|
11
|
+
customMapping?: StateAttributesMapping<State>,
|
|
12
|
+
): Record<string, string> {
|
|
13
|
+
const props: Record<string, string> = {};
|
|
14
|
+
|
|
15
|
+
for (const key in state) {
|
|
16
|
+
const value = state[key];
|
|
17
|
+
|
|
18
|
+
if (customMapping?.hasOwnProperty(key)) {
|
|
19
|
+
const customProps = customMapping[key]!(value);
|
|
20
|
+
if (customProps != null) {
|
|
21
|
+
Object.assign(props, customProps);
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (value === true) {
|
|
27
|
+
props[`data-${key.toLowerCase()}`] = '';
|
|
28
|
+
} else if (value) {
|
|
29
|
+
props[`data-${key.toLowerCase()}`] = value.toString();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return props;
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Ported from .base-ui/packages/react/src/floating-ui-react/utils/element.ts (matchesFocusVisible).
|
|
2
|
+
// jsdom doesn't match `:focus-visible` reliably (it stays matched while `:focus` is set), so —
|
|
3
|
+
// exactly as Base UI does — return `true` under jsdom to avoid the focus-restore dance. Both the
|
|
4
|
+
// octane and React trees run in the SAME jsdom in the differential rig, so this must agree.
|
|
5
|
+
const isJSDOM = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent);
|
|
6
|
+
|
|
7
|
+
export function matchesFocusVisible(element: Element | null): boolean {
|
|
8
|
+
if (!element || isJSDOM) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return element.matches(':focus-visible');
|
|
13
|
+
} catch {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Ported from @base-ui/utils/mergeObjects
|
|
2
|
+
// (.base-ui/packages/utils/src/mergeObjects.ts). Shallow object merge that
|
|
3
|
+
// avoids allocating when only one side is present.
|
|
4
|
+
export function mergeObjects<A extends object | undefined, B extends object | undefined>(
|
|
5
|
+
a: A,
|
|
6
|
+
b: B,
|
|
7
|
+
): A | B | (A & B) | undefined {
|
|
8
|
+
if (a && !b) {
|
|
9
|
+
return a;
|
|
10
|
+
}
|
|
11
|
+
if (!a && b) {
|
|
12
|
+
return b;
|
|
13
|
+
}
|
|
14
|
+
if (a || b) {
|
|
15
|
+
return { ...a, ...b } as A & B;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|