@proyecto-viviana/solidaria-components 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proyecto-viviana/solidaria-components",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Pre-wired headless components for SolidJS - port of react-aria-components",
5
5
  "keywords": [
6
6
  "accessibility",
package/src/Button.tsx CHANGED
@@ -97,6 +97,44 @@ function createLiveCustomRootProps(
97
97
  return props;
98
98
  }
99
99
 
100
+ const buttonAriaOverrideProps = [
101
+ "aria-label",
102
+ "aria-labelledby",
103
+ "aria-describedby",
104
+ "aria-details",
105
+ "aria-haspopup",
106
+ "aria-expanded",
107
+ "aria-controls",
108
+ "aria-pressed",
109
+ "aria-current",
110
+ "aria-disabled",
111
+ ] as const;
112
+
113
+ function createForwardedAriaButtonProps(
114
+ source: AriaButtonProps,
115
+ overrides: AriaButtonProps,
116
+ ): AriaButtonProps {
117
+ const result = {} as AriaButtonProps;
118
+
119
+ for (const key in source) {
120
+ Object.defineProperty(result, key, {
121
+ enumerable: true,
122
+ configurable: true,
123
+ get() {
124
+ return (source as Record<string, unknown>)[key];
125
+ },
126
+ });
127
+ }
128
+
129
+ for (const key in overrides) {
130
+ const descriptor = Object.getOwnPropertyDescriptor(overrides, key);
131
+ if (!descriptor) continue;
132
+ Object.defineProperty(result, key, { ...descriptor, enumerable: true, configurable: true });
133
+ }
134
+
135
+ return result;
136
+ }
137
+
100
138
  export interface ButtonRenderProps {
101
139
  /** Whether the button is currently hovered with a mouse. */
102
140
  isHovered: boolean;
@@ -238,28 +276,29 @@ export function Button(props: ButtonProps): JSX.Element {
238
276
  }
239
277
  };
240
278
 
241
- const buttonAria = createButton({
242
- ...ariaProps,
243
- onPress: handlePress,
244
- get onPressStart() {
245
- return resolvePending() ? undefined : ariaProps.onPressStart;
246
- },
247
- get onPressEnd() {
248
- return resolvePending() ? undefined : ariaProps.onPressEnd;
249
- },
250
- get onPressUp() {
251
- return resolvePending() ? undefined : ariaProps.onPressUp;
252
- },
253
- get onPressChange() {
254
- return resolvePending() ? undefined : ariaProps.onPressChange;
255
- },
256
- get onClick() {
257
- return resolvePending() ? undefined : ariaProps.onClick;
258
- },
259
- get isDisabled() {
260
- return resolveDisabled() || resolvePending();
261
- },
262
- });
279
+ const buttonAria = createButton(
280
+ createForwardedAriaButtonProps(ariaProps, {
281
+ onPress: handlePress,
282
+ get onPressStart() {
283
+ return resolvePending() ? undefined : ariaProps.onPressStart;
284
+ },
285
+ get onPressEnd() {
286
+ return resolvePending() ? undefined : ariaProps.onPressEnd;
287
+ },
288
+ get onPressUp() {
289
+ return resolvePending() ? undefined : ariaProps.onPressUp;
290
+ },
291
+ get onPressChange() {
292
+ return resolvePending() ? undefined : ariaProps.onPressChange;
293
+ },
294
+ get onClick() {
295
+ return resolvePending() ? undefined : ariaProps.onClick;
296
+ },
297
+ get isDisabled() {
298
+ return resolveDisabled() || resolvePending();
299
+ },
300
+ }),
301
+ );
263
302
 
264
303
  const { isFocused, isFocusVisible, focusProps } = createFocusRing();
265
304
 
@@ -394,6 +433,13 @@ export function Button(props: ButtonProps): JSX.Element {
394
433
  }
395
434
  return next;
396
435
  };
436
+ const directAriaProps = () => {
437
+ const next: Record<string, unknown> = {};
438
+ for (const name of buttonAriaOverrideProps) {
439
+ next[name] = (ariaProps as Record<string, unknown>)[name];
440
+ }
441
+ return next;
442
+ };
397
443
  const disablePendingInteractions = (props: Record<string, unknown>) => {
398
444
  if (!resolvePending()) {
399
445
  return props;
@@ -433,6 +479,7 @@ export function Button(props: ButtonProps): JSX.Element {
433
479
  ({
434
480
  ...domProps(),
435
481
  ...disablePendingInteractions(cleanButtonProps()),
482
+ ...directAriaProps(),
436
483
  ...triggerAriaProps(),
437
484
  ...cleanFocusProps(),
438
485
  ...cleanHoverProps(),
package/src/ComboBox.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type JSX,
10
10
  type Accessor,
11
11
  createContext,
12
+ createEffect,
12
13
  createMemo,
13
14
  onCleanup,
14
15
  splitProps,
@@ -290,6 +291,7 @@ export interface ComboBoxOptionProps<T>
290
291
 
291
292
  interface ComboBoxContextValue<T> {
292
293
  state: ComboBoxState<T>;
294
+ listState: ListState<T>;
293
295
  inputProps: () => JSX.InputHTMLAttributes<HTMLInputElement>;
294
296
  buttonProps: () => JSX.HTMLAttributes<HTMLElement>;
295
297
  listBoxProps: () => JSX.HTMLAttributes<HTMLElement>;
@@ -308,13 +310,31 @@ interface ComboBoxContextValue<T> {
308
310
  setTriggerRef: (el: HTMLElement | null) => void;
309
311
  listBoxRef: () => HTMLElement | null;
310
312
  setListBoxRef: (el: HTMLElement | null) => void;
313
+ registerOptionAction: (key: Key, action: (() => void) | undefined) => void;
314
+ runOptionAction: (key: Key) => void;
311
315
  slots?: Record<string, Partial<ComboBoxProps<T>>>;
312
316
  }
313
317
 
318
+ type InputKeyboardEvent = KeyboardEvent & {
319
+ currentTarget: HTMLInputElement;
320
+ target: Element;
321
+ };
322
+
314
323
  export const ComboBoxContext = createContext<ComboBoxContextValue<unknown> | null>(null);
315
324
  export const ComboBoxStateContext = createContext<ComboBoxState<unknown> | null>(null);
316
325
  export const ComboBoxValueContext = ComboBoxContext;
317
326
 
327
+ function callInputKeyDown(
328
+ handler: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent> | undefined,
329
+ event: InputKeyboardEvent,
330
+ ) {
331
+ if (typeof handler === "function") {
332
+ handler(event);
333
+ } else if (handler) {
334
+ handler[0](handler[1], event);
335
+ }
336
+ }
337
+
318
338
  /**
319
339
  * A combobox combines a text input with a listbox, allowing users to filter a list of options.
320
340
  */
@@ -360,6 +380,10 @@ export function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
360
380
  let buttonRef: HTMLElement | null = null;
361
381
  let triggerRef: HTMLElement | null = null;
362
382
  let listBoxRef: HTMLElement | null = null;
383
+ const optionActions = new Map<Key, () => void>();
384
+ const runOptionAction = (key: Key) => {
385
+ optionActions.get(key)?.();
386
+ };
363
387
 
364
388
  const state = createComboBoxState<T>({
365
389
  get items() {
@@ -441,6 +465,7 @@ export function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
441
465
  return ariaProps.isRequired;
442
466
  },
443
467
  });
468
+ const listState = createComboBoxListStateAdapter(state);
444
469
 
445
470
  const effectiveFormValue = createMemo<"key" | "text">(() => {
446
471
  if (stateProps.allowsCustomValue) {
@@ -472,6 +497,28 @@ export function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
472
497
  () => listBoxRef,
473
498
  );
474
499
 
500
+ const getInputProps = () => {
501
+ const inputProps = comboBoxAria.inputProps;
502
+ const originalOnKeyDown = inputProps.onKeyDown;
503
+
504
+ return {
505
+ ...inputProps,
506
+ onKeyDown: (event: InputKeyboardEvent) => {
507
+ const focusedKey = state.focusedKey();
508
+ const shouldRunAction =
509
+ event.key === "Enter" &&
510
+ state.isOpen() &&
511
+ focusedKey != null &&
512
+ !state.isKeyDisabled(focusedKey);
513
+ const optionAction = shouldRunAction ? optionActions.get(focusedKey) : undefined;
514
+
515
+ callInputKeyDown(originalOnKeyDown, event);
516
+
517
+ optionAction?.();
518
+ },
519
+ } as JSX.InputHTMLAttributes<HTMLInputElement>;
520
+ };
521
+
475
522
  const { isHovered, hoverProps } = createHover({
476
523
  get isDisabled() {
477
524
  return ariaProps.isDisabled;
@@ -519,9 +566,13 @@ export function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
519
566
  value={
520
567
  {
521
568
  state,
522
- inputProps: () => comboBoxAria.inputProps,
569
+ listState,
570
+ inputProps: getInputProps,
523
571
  buttonProps: () => comboBoxAria.buttonProps,
524
- listBoxProps: () => comboBoxAria.listBoxProps,
572
+ listBoxProps: () => ({
573
+ ...comboBoxAria.listBoxProps,
574
+ onAction: runOptionAction,
575
+ }),
525
576
  labelProps: () => comboBoxAria.labelProps,
526
577
  descriptionProps: () => comboBoxAria.descriptionProps,
527
578
  errorMessageProps: () => comboBoxAria.errorMessageProps,
@@ -545,6 +596,14 @@ export function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
545
596
  setListBoxRef: (el) => {
546
597
  listBoxRef = el;
547
598
  },
599
+ registerOptionAction: (key, action) => {
600
+ if (action) {
601
+ optionActions.set(key, action);
602
+ } else {
603
+ optionActions.delete(key);
604
+ }
605
+ },
606
+ runOptionAction,
548
607
  slots: local.slots,
549
608
  } as ComboBoxContextValue<unknown>
550
609
  }
@@ -850,7 +909,7 @@ export function ComboBoxListBox<T>(props: ComboBoxListBoxProps<T>): JSX.Element
850
909
  throw new Error("ComboBoxListBox must be used within a ComboBox");
851
910
  }
852
911
  const context = rawContext as ComboBoxContextValue<T>;
853
- const { state: comboBoxState, isOpen, inputRef, buttonRef, setListBoxRef } = context;
912
+ const { state: comboBoxState, listState, isOpen, inputRef, buttonRef, setListBoxRef } = context;
854
913
  const state = comboBoxState;
855
914
 
856
915
  let listBoxRef: HTMLUListElement | undefined;
@@ -880,7 +939,7 @@ export function ComboBoxListBox<T>(props: ComboBoxListBoxProps<T>): JSX.Element
880
939
  // Create listbox aria props using ComboBoxState's ListState-compatible interface
881
940
  const { listBoxProps } = createListBox(
882
941
  context.listBoxProps as unknown as AriaListBoxProps,
883
- createComboBoxListStateAdapter(state),
942
+ listState,
884
943
  );
885
944
 
886
945
  const renderValues = createMemo<ComboBoxListBoxRenderProps>(() => ({
@@ -976,15 +1035,24 @@ export function ComboBoxOption<T>(props: ComboBoxOptionProps<T>): JSX.Element {
976
1035
 
977
1036
  const stateContext = useContext(ComboBoxStateContext);
978
1037
  const comboBoxContext = useContext(ComboBoxContext);
979
- if (!stateContext) {
1038
+ if (!stateContext || !comboBoxContext) {
980
1039
  throw new Error("ComboBoxOption must be used within a ComboBox");
981
1040
  }
982
1041
  const state = stateContext as ComboBoxState<T>;
1042
+ const listState = (comboBoxContext as ComboBoxContextValue<T>).listState;
983
1043
  const optionId = () => {
984
1044
  const listBoxId = getComboBoxData(state as ComboBoxState<unknown>)?.listBoxId;
985
1045
  return listBoxId ? `${listBoxId}-option-${local.id}` : String(local.id);
986
1046
  };
987
1047
 
1048
+ createEffect(() => {
1049
+ const key = local.id;
1050
+ comboBoxContext?.registerOptionAction(key, local.onAction);
1051
+ onCleanup(() => {
1052
+ comboBoxContext?.registerOptionAction(key, undefined);
1053
+ });
1054
+ });
1055
+
988
1056
  // Create option aria props using ComboBoxState's ListState-compatible interface
989
1057
  const optionAria = createOption<T>(
990
1058
  {
@@ -998,9 +1066,6 @@ export function ComboBoxOption<T>(props: ComboBoxOptionProps<T>): JSX.Element {
998
1066
  get "aria-label"() {
999
1067
  return ariaProps["aria-label"];
1000
1068
  },
1001
- get onAction() {
1002
- return local.onAction;
1003
- },
1004
1069
  shouldSelectOnPressUp: true,
1005
1070
  shouldFocusOnHover: true,
1006
1071
  shouldUseVirtualFocus: true,
@@ -1014,8 +1079,11 @@ export function ComboBoxOption<T>(props: ComboBoxOptionProps<T>): JSX.Element {
1014
1079
  get onHoverChange() {
1015
1080
  return ariaProps.onHoverChange;
1016
1081
  },
1082
+ get onAction() {
1083
+ return local.onAction;
1084
+ },
1017
1085
  },
1018
- createComboBoxListStateAdapter(state),
1086
+ listState,
1019
1087
  );
1020
1088
 
1021
1089
  const isOptionFocusVisible = () =>
@@ -39,6 +39,7 @@ import {
39
39
  type CalendarState,
40
40
  type RangeCalendarState,
41
41
  type DateFieldStateProps,
42
+ type DatePickerStateOptions,
42
43
  type CalendarDate,
43
44
  type DateValue,
44
45
  type RangeCalendarStateProps,
@@ -349,11 +350,65 @@ function DatePickerInner<T extends DateValue = CalendarDate>(
349
350
  const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
350
351
  const [fieldRef, setFieldRef] = createSignal<HTMLDivElement | null>(null);
351
352
 
352
- // Unified state using createDatePickerState as single source of truth
353
- const datePickerState = createDatePickerState<T>({
354
- ...(stateProps as unknown as import("@proyecto-viviana/solid-stately").DatePickerStateOptions<T>),
355
- shouldCloseOnSelect: local.shouldCloseOnSelect,
356
- });
353
+ // Unified state using createDatePickerState as single source of truth.
354
+ // Use getters here so controlled props keep tracking after splitProps.
355
+ const datePickerStateProps = {
356
+ get value() {
357
+ return stateProps.value;
358
+ },
359
+ get defaultValue() {
360
+ return stateProps.defaultValue;
361
+ },
362
+ get onChange() {
363
+ return stateProps.onChange;
364
+ },
365
+ get minValue() {
366
+ return stateProps.minValue;
367
+ },
368
+ get maxValue() {
369
+ return stateProps.maxValue;
370
+ },
371
+ get isDisabled() {
372
+ return stateProps.isDisabled;
373
+ },
374
+ get isReadOnly() {
375
+ return stateProps.isReadOnly;
376
+ },
377
+ get isRequired() {
378
+ return stateProps.isRequired;
379
+ },
380
+ get granularity() {
381
+ return stateProps.granularity;
382
+ },
383
+ get hourCycle() {
384
+ return stateProps.hourCycle;
385
+ },
386
+ get hideTimeZone() {
387
+ return stateProps.hideTimeZone;
388
+ },
389
+ get placeholderValue() {
390
+ return stateProps.placeholderValue;
391
+ },
392
+ get shouldCloseOnSelect() {
393
+ return local.shouldCloseOnSelect;
394
+ },
395
+ get defaultOpen() {
396
+ return stateProps.defaultOpen;
397
+ },
398
+ get isOpen() {
399
+ return stateProps.isOpen;
400
+ },
401
+ get onOpenChange() {
402
+ return stateProps.onOpenChange;
403
+ },
404
+ get isDateUnavailable() {
405
+ return stateProps.isDateUnavailable;
406
+ },
407
+ get validationState() {
408
+ return stateProps.validationState;
409
+ },
410
+ } satisfies DatePickerStateOptions<T>;
411
+ const datePickerState = createDatePickerState<T>(datePickerStateProps);
357
412
 
358
413
  const overlayState = {
359
414
  get isOpen() {
package/src/Menu.tsx CHANGED
@@ -373,6 +373,7 @@ export function SubmenuTrigger(props: SubmenuTriggerProps): JSX.Element {
373
373
  const triggerId = createUniqueId();
374
374
  const menuId = createUniqueId();
375
375
  let hoverTimeout: number | undefined;
376
+ let hasPointerHover = false;
376
377
  const delay = () => props.delay ?? 200;
377
378
 
378
379
  const clearHoverTimeout = () => {
@@ -387,6 +388,16 @@ export function SubmenuTrigger(props: SubmenuTriggerProps): JSX.Element {
387
388
  state.open();
388
389
  };
389
390
 
391
+ const queueOpenSubmenu = () => {
392
+ clearHoverTimeout();
393
+ const open = () => state.open();
394
+ if (typeof queueMicrotask === "function") {
395
+ queueMicrotask(open);
396
+ } else {
397
+ Promise.resolve().then(open);
398
+ }
399
+ };
400
+
390
401
  const scheduleOpen = () => {
391
402
  clearHoverTimeout();
392
403
  hoverTimeout = window.setTimeout(() => {
@@ -395,6 +406,28 @@ export function SubmenuTrigger(props: SubmenuTriggerProps): JSX.Element {
395
406
  }, delay());
396
407
  };
397
408
 
409
+ const schedulePointerOpen = (event: PointerEvent) => {
410
+ hasPointerHover = true;
411
+ if (event.isTrusted === false) {
412
+ queueOpenSubmenu();
413
+ return;
414
+ }
415
+
416
+ scheduleOpen();
417
+ };
418
+
419
+ const openFromMouseHover = () => {
420
+ if (state.isOpen()) {
421
+ return;
422
+ }
423
+
424
+ if (hasPointerHover) {
425
+ scheduleOpen();
426
+ } else {
427
+ queueOpenSubmenu();
428
+ }
429
+ };
430
+
398
431
  onCleanup(clearHoverTimeout);
399
432
 
400
433
  const menuTriggerContext = createMemo<MenuTriggerContextValue>(() => ({
@@ -430,17 +463,22 @@ export function SubmenuTrigger(props: SubmenuTriggerProps): JSX.Element {
430
463
  props: () => ({
431
464
  id: triggerId,
432
465
  "aria-haspopup": "menu",
433
- "aria-expanded": state.isOpen() || undefined,
434
- "aria-controls": state.isOpen() ? menuId : undefined,
466
+ get "aria-expanded"() {
467
+ return state.isOpen() || undefined;
468
+ },
469
+ get "aria-controls"() {
470
+ return state.isOpen() ? menuId : undefined;
471
+ },
435
472
  onPointerEnter: (event: PointerEvent) => {
436
473
  if (event.pointerType === "touch") return;
437
- scheduleOpen();
474
+ schedulePointerOpen(event);
438
475
  },
439
476
  onPointerOver: (event: PointerEvent) => {
440
477
  if (event.pointerType === "touch") return;
441
- scheduleOpen();
478
+ schedulePointerOpen(event);
442
479
  },
443
- onMouseEnter: () => scheduleOpen(),
480
+ onMouseEnter: openFromMouseHover,
481
+ onMouseOver: openFromMouseHover,
444
482
  onKeyDown: (event: KeyboardEvent) => {
445
483
  if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") {
446
484
  event.preventDefault();
@@ -1028,6 +1066,11 @@ export function Menu<T>(props: MenuProps<T>): JSX.Element {
1028
1066
  dndDropIndicator(index, position) ??
1029
1067
  parentCollectionRenderer?.renderDropIndicator?.(index, position),
1030
1068
  }));
1069
+ const menuItemContextValue = createMemo<MenuItemContextValue>(() =>
1070
+ local.shouldCloseOnSelect !== undefined
1071
+ ? { closeOnSelect: local.shouldCloseOnSelect }
1072
+ : {},
1073
+ );
1031
1074
  const menuListChildren = () => (
1032
1075
  <SharedElementTransition>
1033
1076
  {state.collection().size === 0 && !usesStaticChildren() && local.renderEmptyState ? (
@@ -1156,7 +1199,7 @@ export function Menu<T>(props: MenuProps<T>): JSX.Element {
1156
1199
  <StaticMenuCollectionContext.Provider
1157
1200
  value={usesStaticChildren() ? staticCollectionContext : null}
1158
1201
  >
1159
- <MenuItemContext.Provider value={{ closeOnSelect: local.shouldCloseOnSelect }}>
1202
+ <MenuItemContext.Provider value={menuItemContextValue()}>
1160
1203
  <CollectionRendererContext.Provider value={collectionRenderer()}>
1161
1204
  <>
1162
1205
  <Show when={ariaProps.label}>
@@ -525,9 +525,19 @@ export function NumberFieldIncrementButton(props: NumberFieldIncrementButtonProp
525
525
  }
526
526
 
527
527
  const isDisabled = () => context.isDisabled || !context.state.canIncrement();
528
+ const pressButtonProps = () => {
529
+ const {
530
+ onClick: _onClick,
531
+ disabled: _disabled,
532
+ type: _type,
533
+ tabIndex: _tabIndex,
534
+ ...rest
535
+ } = context.incrementButtonProps as Record<string, unknown>;
536
+ return rest;
537
+ };
528
538
 
529
539
  const buttonAria = createButton({
530
- ...(context.incrementButtonProps as Record<string, unknown>),
540
+ ...pressButtonProps(),
531
541
  elementType: "div",
532
542
  get isDisabled() {
533
543
  return isDisabled();
@@ -594,9 +604,19 @@ export function NumberFieldDecrementButton(props: NumberFieldDecrementButtonProp
594
604
  }
595
605
 
596
606
  const isDisabled = () => context.isDisabled || !context.state.canDecrement();
607
+ const pressButtonProps = () => {
608
+ const {
609
+ onClick: _onClick,
610
+ disabled: _disabled,
611
+ type: _type,
612
+ tabIndex: _tabIndex,
613
+ ...rest
614
+ } = context.decrementButtonProps as Record<string, unknown>;
615
+ return rest;
616
+ };
597
617
 
598
618
  const buttonAria = createButton({
599
- ...(context.decrementButtonProps as Record<string, unknown>),
619
+ ...pressButtonProps(),
600
620
  elementType: "div",
601
621
  get isDisabled() {
602
622
  return isDisabled();
package/src/Popover.tsx CHANGED
@@ -480,6 +480,14 @@ export function Popover(props: PopoverProps): JSX.Element {
480
480
  };
481
481
 
482
482
  const shouldBeDialog = () => !local.isNonModal || resolvedTrigger() === "SubmenuTrigger";
483
+ const shouldContainFocus = () => {
484
+ if (!shouldBeDialog()) {
485
+ return false;
486
+ }
487
+
488
+ const trigger = resolvedTrigger();
489
+ return trigger !== "MenuTrigger" && trigger !== "SubmenuTrigger";
490
+ };
483
491
  const portalContext = useUNSAFE_PortalContext();
484
492
  const portalContainer = () => {
485
493
  if (isSubPopover()) {
@@ -547,7 +555,7 @@ export function Popover(props: PopoverProps): JSX.Element {
547
555
  <PopoverContext.Provider
548
556
  value={{ placement: popoverAria.placement, arrowProps: () => popoverAria.arrowProps }}
549
557
  >
550
- <FocusScope contain={shouldBeDialog()} restoreFocus>
558
+ <FocusScope contain={shouldContainFocus()} restoreFocus>
551
559
  <div
552
560
  {...domProps()}
553
561
  {...cleanPopoverProps()}
package/src/TagGroup.tsx CHANGED
@@ -365,6 +365,7 @@ export function Tag(props: TagProps): JSX.Element {
365
365
  get key() {
366
366
  return local.id;
367
367
  },
368
+ role: "row",
368
369
  get isDisabled() {
369
370
  return local.isDisabled || groupContext?.isDisabled;
370
371
  },
package/src/TextField.tsx CHANGED
@@ -79,6 +79,7 @@ export interface TextFieldContextValue {
79
79
  isInvalid?: boolean;
80
80
  slots?: Record<string, TextFieldProps>;
81
81
  inputId?: string;
82
+ setInputId?: (id: string | undefined) => void;
82
83
  }
83
84
 
84
85
  export const TextFieldContext = createContext<TextFieldContextValue | null>(null);
@@ -204,6 +205,14 @@ export function Input(props: InputProps): JSX.Element {
204
205
  const context = useContext(TextFieldContext);
205
206
  let inputElement: HTMLInputElement | undefined;
206
207
 
208
+ createEffect(() => {
209
+ context?.setInputId?.(props.id);
210
+ });
211
+
212
+ onCleanup(() => {
213
+ context?.setInputId?.(undefined);
214
+ });
215
+
207
216
  // Merge context inputProps with local props (local props take precedence)
208
217
  const mergedProps = () => {
209
218
  if (context) {
@@ -291,6 +300,14 @@ export function TextArea(props: TextAreaProps): JSX.Element {
291
300
  const context = useContext(TextFieldContext);
292
301
  let textAreaElement: HTMLTextAreaElement | undefined;
293
302
 
303
+ createEffect(() => {
304
+ context?.setInputId?.(props.id);
305
+ });
306
+
307
+ onCleanup(() => {
308
+ context?.setInputId?.(undefined);
309
+ });
310
+
294
311
  // Merge context inputProps with local props (local props take precedence)
295
312
  // Note: TextArea uses inputProps from context since it's an input variant
296
313
  const mergedProps = () => {
@@ -396,6 +413,7 @@ export function TextField(props: TextFieldProps): JSX.Element {
396
413
  errorMessageProps: _errorMessageProps,
397
414
  isInvalid: _isInvalid,
398
415
  slots: _slots,
416
+ setInputId: _setInputId,
399
417
  ...rest
400
418
  } = contextProps;
401
419
  return rest as TextFieldProps;
@@ -449,6 +467,7 @@ export function TextField(props: TextFieldProps): JSX.Element {
449
467
  return ariaProps.isDisabled;
450
468
  },
451
469
  });
470
+ const [inputIdOverride, setInputIdOverride] = createSignal<string | undefined>();
452
471
 
453
472
  const renderValues = createMemo<TextFieldRenderProps>(() => ({
454
473
  isDisabled: ariaProps.isDisabled || false,
@@ -551,7 +570,10 @@ export function TextField(props: TextFieldProps): JSX.Element {
551
570
  // onMount effect would flip undefined->id post-mount and re-execute the Label
552
571
  // template — crashing hydration if the re-run lands mid-hydration (dev).
553
572
  get inputId() {
554
- return (textFieldAria.inputProps as { id?: string }).id;
573
+ return inputIdOverride() ?? (textFieldAria.inputProps as { id?: string }).id;
574
+ },
575
+ setInputId(id: string | undefined) {
576
+ setInputIdOverride(id);
555
577
  },
556
578
  };
557
579
  // Resolve the render-prop children ONCE (untracked). Re-invoking it on a
@@ -559,10 +581,11 @@ export function TextField(props: TextFieldProps): JSX.Element {
559
581
  // throws a Hydration Mismatch (worst in dev, where slow unbundled modules widen
560
582
  // the hydration window). The children carry their own fine-grained reactivity
561
583
  // (render-value getters + <Show>s), so they update without being re-created.
562
- const fieldChildren = untrack(() => {
563
- const children = local.children;
564
- return typeof children === "function" ? children(childRenderValues) : children;
565
- });
584
+ const FieldChildren = () =>
585
+ untrack(() => {
586
+ const children = local.children;
587
+ return typeof children === "function" ? children(childRenderValues) : children;
588
+ });
566
589
  const rootProps = () =>
567
590
  ({
568
591
  ...domProps(),
@@ -581,7 +604,7 @@ export function TextField(props: TextFieldProps): JSX.Element {
581
604
  const customRootProps = () =>
582
605
  ({
583
606
  ...rootProps(),
584
- children: fieldChildren,
607
+ children: <FieldChildren />,
585
608
  }) as JSX.HTMLAttributes<HTMLDivElement>;
586
609
 
587
610
  return (
@@ -590,7 +613,9 @@ export function TextField(props: TextFieldProps): JSX.Element {
590
613
  {local.render ? (
591
614
  local.render(customRootProps(), renderValues())
592
615
  ) : (
593
- <div {...rootProps()}>{fieldChildren}</div>
616
+ <div {...rootProps()}>
617
+ <FieldChildren />
618
+ </div>
594
619
  )}
595
620
  </TextFieldContext.Provider>
596
621
  </FieldErrorContext.Provider>