@proyecto-viviana/solid-stately 0.6.0-rc.0 → 0.6.0-rc.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 (127) hide show
  1. package/README.md +59 -29
  2. package/dist/calendar/createCalendarState.d.ts +3 -1
  3. package/dist/calendar/createCalendarState.d.ts.map +1 -1
  4. package/dist/calendar/createDatePickerState.d.ts +2 -0
  5. package/dist/calendar/createDatePickerState.d.ts.map +1 -1
  6. package/dist/calendar/createRangeCalendarState.d.ts +2 -0
  7. package/dist/calendar/createRangeCalendarState.d.ts.map +1 -1
  8. package/dist/calendar/createTimeFieldState.d.ts +1 -1
  9. package/dist/calendar/createTimeFieldState.d.ts.map +1 -1
  10. package/dist/checkbox/createCheckboxGroupState.d.ts +2 -0
  11. package/dist/checkbox/createCheckboxGroupState.d.ts.map +1 -1
  12. package/dist/collections/createListState.d.ts.map +1 -1
  13. package/dist/collections/createMenuState.d.ts +46 -2
  14. package/dist/collections/createMenuState.d.ts.map +1 -1
  15. package/dist/collections/createSelectionState.d.ts.map +1 -1
  16. package/dist/collections/index.d.ts +1 -1
  17. package/dist/collections/index.d.ts.map +1 -1
  18. package/dist/color/createColorAreaState.d.ts +4 -0
  19. package/dist/color/createColorAreaState.d.ts.map +1 -1
  20. package/dist/color/createColorFieldState.d.ts +2 -0
  21. package/dist/color/createColorFieldState.d.ts.map +1 -1
  22. package/dist/color/createColorSliderState.d.ts +2 -0
  23. package/dist/color/createColorSliderState.d.ts.map +1 -1
  24. package/dist/color/createColorWheelState.d.ts.map +1 -1
  25. package/dist/combobox/createComboBoxState.d.ts +90 -23
  26. package/dist/combobox/createComboBoxState.d.ts.map +1 -1
  27. package/dist/combobox/index.d.ts +1 -1
  28. package/dist/combobox/index.d.ts.map +1 -1
  29. package/dist/data/createAsyncList.d.ts.map +1 -1
  30. package/dist/data/createListData.d.ts.map +1 -1
  31. package/dist/data/createTreeData.d.ts.map +1 -1
  32. package/dist/dnd/createDraggableCollectionState.d.ts +5 -1
  33. package/dist/dnd/createDraggableCollectionState.d.ts.map +1 -1
  34. package/dist/dnd/createDroppableCollectionState.d.ts +6 -0
  35. package/dist/dnd/createDroppableCollectionState.d.ts.map +1 -1
  36. package/dist/form/createFormValidationState.d.ts.map +1 -1
  37. package/dist/grid/createGridState.d.ts +2 -2
  38. package/dist/grid/createGridState.d.ts.map +1 -1
  39. package/dist/index.d.ts +5 -5
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1012 -471
  42. package/dist/index.js.map +1 -1
  43. package/dist/menu/index.d.ts +2 -1
  44. package/dist/menu/index.d.ts.map +1 -1
  45. package/dist/numberfield/createNumberFieldState.d.ts +6 -3
  46. package/dist/numberfield/createNumberFieldState.d.ts.map +1 -1
  47. package/dist/private/flags/flags.d.ts.map +1 -0
  48. package/dist/private/flags/flags.js +1 -1
  49. package/dist/private/flags/flags.js.map +1 -1
  50. package/dist/radio/createRadioGroupState.d.ts.map +1 -1
  51. package/dist/select/createSelectState.d.ts +85 -29
  52. package/dist/select/createSelectState.d.ts.map +1 -1
  53. package/dist/select/index.d.ts +1 -1
  54. package/dist/select/index.d.ts.map +1 -1
  55. package/dist/selection/SelectionManager.d.ts.map +1 -1
  56. package/dist/selection/createMultipleSelectionState.d.ts.map +1 -1
  57. package/dist/slider/createSliderState.d.ts +35 -9
  58. package/dist/slider/createSliderState.d.ts.map +1 -1
  59. package/dist/ssr/index.d.ts +0 -6
  60. package/dist/ssr/index.d.ts.map +1 -1
  61. package/dist/steplist/createStepListState.d.ts +8 -13
  62. package/dist/steplist/createStepListState.d.ts.map +1 -1
  63. package/dist/table/createTableColumnResizeState.d.ts.map +1 -1
  64. package/dist/table/createTableState.d.ts +2 -2
  65. package/dist/table/createTableState.d.ts.map +1 -1
  66. package/dist/table/createTreeGridState.d.ts +2 -2
  67. package/dist/table/createTreeGridState.d.ts.map +1 -1
  68. package/dist/table/types.d.ts +2 -2
  69. package/dist/table/types.d.ts.map +1 -1
  70. package/dist/tabs/createTabListState.d.ts.map +1 -1
  71. package/dist/tokenfield/TokenFieldValue.d.ts +28 -4
  72. package/dist/tokenfield/TokenFieldValue.d.ts.map +1 -1
  73. package/dist/tokenfield/index.d.ts +1 -1
  74. package/dist/tokenfield/index.d.ts.map +1 -1
  75. package/dist/tree/createTreeState.d.ts +2 -2
  76. package/dist/tree/createTreeState.d.ts.map +1 -1
  77. package/dist/utils/index.d.ts +1 -1
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/reactivity.d.ts +5 -0
  80. package/dist/utils/reactivity.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/calendar/createCalendarState.ts +139 -58
  83. package/src/calendar/createDatePickerState.ts +14 -11
  84. package/src/calendar/createRangeCalendarState.ts +43 -17
  85. package/src/calendar/createTimeFieldState.ts +1 -1
  86. package/src/checkbox/createCheckboxGroupState.ts +16 -0
  87. package/src/collections/createListState.ts +4 -4
  88. package/src/collections/createMenuState.ts +134 -4
  89. package/src/collections/createSelectionState.ts +2 -2
  90. package/src/collections/index.ts +4 -0
  91. package/src/color/createColorAreaState.ts +19 -0
  92. package/src/color/createColorFieldState.ts +76 -26
  93. package/src/color/createColorSliderState.ts +25 -2
  94. package/src/color/createColorWheelState.ts +9 -5
  95. package/src/combobox/createComboBoxState.ts +371 -121
  96. package/src/combobox/index.ts +4 -0
  97. package/src/data/createAsyncList.ts +19 -4
  98. package/src/data/createListData.ts +8 -2
  99. package/src/data/createTreeData.ts +11 -3
  100. package/src/dnd/createDraggableCollectionState.ts +8 -1
  101. package/src/dnd/createDroppableCollectionState.ts +10 -0
  102. package/src/form/createFormValidationState.ts +17 -3
  103. package/src/grid/createGridState.ts +15 -8
  104. package/src/index.ts +18 -2
  105. package/src/menu/index.ts +5 -0
  106. package/src/numberfield/createNumberFieldState.ts +97 -35
  107. package/src/radio/createRadioGroupState.ts +4 -1
  108. package/src/select/createSelectState.ts +253 -131
  109. package/src/select/index.ts +8 -1
  110. package/src/selection/SelectionManager.ts +3 -1
  111. package/src/selection/createMultipleSelectionState.ts +2 -2
  112. package/src/slider/createSliderState.ts +227 -103
  113. package/src/ssr/index.ts +11 -4
  114. package/src/steplist/createStepListState.ts +156 -123
  115. package/src/table/createTableColumnResizeState.ts +51 -11
  116. package/src/table/createTableState.ts +5 -4
  117. package/src/table/createTreeGridState.ts +3 -4
  118. package/src/table/types.ts +2 -2
  119. package/src/tabs/createTabListState.ts +8 -31
  120. package/src/tokenfield/TokenFieldValue.ts +75 -16
  121. package/src/tokenfield/index.ts +1 -0
  122. package/src/tree/createTreeState.ts +15 -8
  123. package/src/utils/index.ts +1 -0
  124. package/src/utils/reactivity.ts +8 -0
  125. package/dist/flags/flags.d.ts.map +0 -1
  126. /package/dist/{flags → private/flags}/flags.d.ts +0 -0
  127. /package/src/{flags → private/flags}/flags.ts +0 -0
@@ -107,6 +107,8 @@ export interface RangeCalendarStateProps<T extends DateValue = DateValue> {
107
107
  isDateDisabled?: (date: DateValue) => boolean;
108
108
  /** Validation state. */
109
109
  validationState?: MaybeAccessor<ValidationState | undefined>;
110
+ /** Whether the current selection is invalid according to application logic. */
111
+ isInvalid?: boolean;
110
112
  /** Whether to allow selecting the same date for start and end. */
111
113
  allowsNonContiguousRanges?: boolean;
112
114
  /** The first day of the week (0 = Sunday, 1 = Monday, etc.). */
@@ -315,10 +317,14 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
315
317
 
316
318
  const rawAlignStart = (date: CalendarDate): CalendarDate => startOfMonth(date);
317
319
 
318
- const rawAlignEnd = (date: CalendarDate): CalendarDate =>
319
- startOfMonth(date).subtract({ months: Math.max(visibleMonths() - 1, 0) });
320
+ const rawAlignEnd = (date: CalendarDate, months = visibleMonths()): CalendarDate =>
321
+ startOfMonth(date).subtract({ months: Math.max(months - 1, 0) });
320
322
 
321
- const constrainVisibleRangeStart = (date: CalendarDate, aligned: CalendarDate): CalendarDate => {
323
+ const constrainVisibleRangeStart = (
324
+ date: CalendarDate,
325
+ aligned: CalendarDate,
326
+ months = visibleMonths(),
327
+ ): CalendarDate => {
322
328
  const minValue = access(props.minValue);
323
329
  const maxValue = access(props.maxValue);
324
330
  let constrained = aligned;
@@ -331,7 +337,7 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
331
337
  }
332
338
 
333
339
  if (maxValue && date.compare(toDisplayCalendarDate(maxValue)) <= 0) {
334
- const maxStart = rawAlignEnd(toDisplayCalendarDate(maxValue));
340
+ const maxStart = rawAlignEnd(toDisplayCalendarDate(maxValue), months);
335
341
  if (maxStart.compare(constrained) < 0) {
336
342
  constrained = maxStart;
337
343
  }
@@ -355,8 +361,11 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
355
361
  return constrainVisibleRangeStart(date, rawAlignStart(date).subtract({ months: offset }));
356
362
  };
357
363
 
364
+ // A controlled null is a cleared selection. Pin useControlledState treats
365
+ // `value !== undefined` as controlled, so null must not fall through to defaultValue.
358
366
  const defaultSelectionAlignment = (): RangeCalendarSelectionAlignment => {
359
- const currentValue = access(props.value) ?? props.defaultValue;
367
+ const controlled = access(props.value);
368
+ const currentValue = controlled !== undefined ? controlled : props.defaultValue;
360
369
  if (!currentValue?.start || !currentValue?.end) {
361
370
  return "center";
362
371
  }
@@ -388,10 +397,11 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
388
397
  return toDisplayCalendarDate(props.defaultFocusedValue);
389
398
  }
390
399
  const controlledValue = access(props.value);
391
- if (controlledValue?.start) {
392
- return toDisplayCalendarDate(controlledValue.start);
393
- }
394
- if (props.defaultValue?.start) {
400
+ if (controlledValue !== undefined) {
401
+ if (controlledValue?.start) {
402
+ return toDisplayCalendarDate(controlledValue.start);
403
+ }
404
+ } else if (props.defaultValue?.start) {
395
405
  return toDisplayCalendarDate(props.defaultValue.start);
396
406
  }
397
407
  return intlToCalendar(today(timeZone), calendar());
@@ -451,7 +461,10 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
451
461
  isDateOutsideRange(toDisplayCalendarDate(v.end), minCal, maxCal)
452
462
  );
453
463
  });
454
- const isValueInvalid = createMemo(() => validationState() === "invalid" || isInvalidSelection());
464
+ const isValueInvalid = createMemo(
465
+ () =>
466
+ Boolean(access(props.isInvalid)) || validationState() === "invalid" || isInvalidSelection(),
467
+ );
455
468
 
456
469
  // Highlighted range during selection
457
470
  const highlightedRange = createMemo<RangeValue<CalendarDate> | null>(() => {
@@ -736,17 +749,21 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
736
749
  // advance the grid.
737
750
  const focusPreviousPage = () => {
738
751
  const pageMonths = access(props.pageBehavior) === "single" ? 1 : visibleMonths();
739
- const nextFocusedDate = constrainDate(focusedDate().subtract({ months: pageMonths }));
752
+ const currentFocused = focusedDate();
753
+ const nextFocusedDate = constrainDate(currentFocused.subtract({ months: pageMonths }));
754
+ const slidStart = startOfMonth(visibleRangeStart().subtract({ months: pageMonths }));
740
755
  setFocusedDateInternal(nextFocusedDate);
741
- setVisibleRangeStart(startOfMonth(visibleRangeStart().subtract({ months: pageMonths })));
756
+ setVisibleRangeStart(constrainVisibleRangeStart(currentFocused, slidStart, pageMonths));
742
757
  props.onFocusChange?.(nextFocusedDate);
743
758
  };
744
759
 
745
760
  const focusNextPage = () => {
746
761
  const pageMonths = access(props.pageBehavior) === "single" ? 1 : visibleMonths();
747
- const nextFocusedDate = constrainDate(focusedDate().add({ months: pageMonths }));
762
+ const currentFocused = focusedDate();
763
+ const nextFocusedDate = constrainDate(currentFocused.add({ months: pageMonths }));
764
+ const slidStart = startOfMonth(visibleRangeStart().add({ months: pageMonths }));
748
765
  setFocusedDateInternal(nextFocusedDate);
749
- setVisibleRangeStart(startOfMonth(visibleRangeStart().add({ months: pageMonths })));
766
+ setVisibleRangeStart(constrainVisibleRangeStart(currentFocused, slidStart, pageMonths));
750
767
  props.onFocusChange?.(nextFocusedDate);
751
768
  };
752
769
 
@@ -881,9 +898,18 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
881
898
  };
882
899
 
883
900
  const focusNearestAvailableDate = (anchor: CalendarDate) => {
884
- // RAC `useRangeCalendarState.ts:261-273`: prefer anchor+1, else −1, skip invalid.
885
- const isDateInvalid = (candidate: CalendarDate) =>
886
- isInvalid(candidate) || isDateOutsideAllowedRange(candidate);
901
+ // RAC `useRangeCalendarState.ts:261-273`: prefer anchor+1, else −1.
902
+ // Invalid is outside min/max or this anchor's contiguous span. A day in
903
+ // the next month, or an unavailable day when non-contiguous ranges are
904
+ // allowed, is still a valid focus target.
905
+ const passedRange = getAvailableRange(anchor);
906
+ const isDateInvalid = (candidate: CalendarDate) => {
907
+ if (isDateOutsideAllowedRange(candidate)) return true;
908
+ const calDate = toDisplayCalendarDate(candidate);
909
+ if (passedRange?.start && calDate.compare(passedRange.start) < 0) return true;
910
+ if (passedRange?.end && calDate.compare(passedRange.end) > 0) return true;
911
+ return false;
912
+ };
887
913
  let nextDay = anchor.add({ days: 1 });
888
914
  if (isDateInvalid(nextDay)) {
889
915
  nextDay = anchor.subtract({ days: 1 });
@@ -122,7 +122,7 @@ export interface TimeFieldStateProps<T extends TimeValue = Time> {
122
122
  * consumers.
123
123
  */
124
124
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
125
- export interface TimeFieldState<T extends TimeValue = Time> extends DateFieldState {
125
+ export interface TimeFieldState<_T extends TimeValue = Time> extends DateFieldState {
126
126
  /** The current value in the `Time`/`CalendarDateTime`/`ZonedDateTime` domain. */
127
127
  timeValue: Accessor<TimeValue | null>;
128
128
  }
@@ -25,6 +25,7 @@ import { createMemo, Accessor } from "solid-js";
25
25
  import { access, createInternalSignal, readNow, type MaybeAccessor } from "../utils";
26
26
  import {
27
27
  createFormValidationState,
28
+ mergeValidation,
28
29
  type FormValidationState,
29
30
  type ValidationFunction,
30
31
  type ValidationResult,
@@ -98,6 +99,8 @@ export interface CheckboxGroupState extends Pick<
98
99
  removeValue(value: string): void;
99
100
  /** Toggles a value in the set of selected values. */
100
101
  toggleValue(value: string): void;
102
+ /** Records one checkbox's validation and merges it into the group. */
103
+ setInvalid(value: string, validation: ValidationResult): void;
101
104
 
102
105
  /** Current display validation result for the group. */
103
106
  readonly displayValidation: Accessor<ValidationResult>;
@@ -195,6 +198,18 @@ export function createCheckboxGroupState(
195
198
  }
196
199
  }
197
200
 
201
+ const invalidValues = new Map<string, ValidationResult>();
202
+
203
+ function setInvalid(itemValue: string, result: ValidationResult): void {
204
+ if (result.isInvalid) {
205
+ invalidValues.set(itemValue, result);
206
+ } else {
207
+ invalidValues.delete(itemValue);
208
+ }
209
+
210
+ validation.updateValidation(mergeValidation(...invalidValues.values()));
211
+ }
212
+
198
213
  function toggleValue(toggleVal: string): void {
199
214
  const p = getProps();
200
215
  if (p.isReadOnly || p.isDisabled) {
@@ -227,6 +242,7 @@ export function createCheckboxGroupState(
227
242
  addValue,
228
243
  removeValue,
229
244
  toggleValue,
245
+ setInvalid,
230
246
  realtimeValidation: validation.realtimeValidation,
231
247
  displayValidation: validation.displayValidation,
232
248
  updateValidation: validation.updateValidation,
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { createMemo, createEffect } from "solid-js";
25
25
  import type { Accessor } from "solid-js";
26
- import { access, type MaybeAccessor } from "../utils";
26
+ import { propsAccessor, type MaybeAccessor } from "../utils";
27
27
  import { ListCollection } from "./ListCollection";
28
28
  import type { SelectionState, SelectionPressEvent } from "./createSelectionState";
29
29
  import { createMultipleSelectionState } from "../selection/createMultipleSelectionState";
@@ -91,7 +91,7 @@ export interface ListState<T = unknown> extends SelectionState {
91
91
  export function createListState<T = unknown>(
92
92
  props: MaybeAccessor<ListStateProps<T>>,
93
93
  ): ListState<T> {
94
- const getProps = () => access(props);
94
+ const getProps = propsAccessor(props);
95
95
 
96
96
  // Build collection from items (memoized to avoid rebuilding on every access)
97
97
  const collection: Accessor<Collection<T>> = createMemo(() => {
@@ -349,7 +349,7 @@ export interface SingleSelectListState<T = unknown> extends ListState<T> {
349
349
  export function createSingleSelectListState<T = unknown>(
350
350
  props: MaybeAccessor<SingleSelectListStateProps<T>>,
351
351
  ): SingleSelectListState<T> {
352
- const getProps = () => access(props);
352
+ const getProps = propsAccessor(props);
353
353
 
354
354
  // Convert single selection props to multiple selection props
355
355
  const listState = createListState<T>({
@@ -376,7 +376,7 @@ export function createSingleSelectListState<T = unknown>(
376
376
  allowDuplicateSelectionEvents: true,
377
377
  get selectedKeys() {
378
378
  const key = getProps().selectedKey;
379
- return key != null ? [key] : [];
379
+ return key !== undefined ? (key != null ? [key] : []) : undefined;
380
380
  },
381
381
  get defaultSelectedKeys() {
382
382
  const key = getProps().defaultSelectedKey;
@@ -10,17 +10,32 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ /*
14
+ * Copyright 2023 Adobe. All rights reserved.
15
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
16
+ * you may not use this file except in compliance with the License. You may obtain a copy
17
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
18
+ *
19
+ * Unless required by applicable law or agreed to in writing, software distributed under
20
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
21
+ * OF ANY KIND, either express or implied. See the License for the specific language
22
+ * governing permissions and limitations under the License.
23
+ */
24
+
13
25
  // Ported to SolidJS for Proyecto Viviana; based on packages/react-stately/src/menu/useMenuTriggerState.ts
26
+ // Ported to SolidJS for Proyecto Viviana; based on packages/react-stately/src/menu/useSubmenuTriggerState.ts
14
27
 
15
28
  /**
16
29
  * State management for menu components.
17
30
  *
18
31
  * createMenuTriggerState is ported from
19
32
  * packages/react-stately/src/menu/useMenuTriggerState.ts.
33
+ * createSubmenuTriggerState is ported from
34
+ * packages/react-stately/src/menu/useSubmenuTriggerState.ts.
20
35
  * createMenuState is a local composition of the separately attributed list state.
21
36
  */
22
37
 
23
- import { access, createInternalSignal, type MaybeAccessor } from "../utils";
38
+ import { createInternalSignal, propsAccessor, type MaybeAccessor } from "../utils";
24
39
 
25
40
  import { createOverlayTriggerState, type OverlayTriggerProps } from "../overlays";
26
41
  import { createListState, type ListState, type ListStateProps } from "./createListState";
@@ -45,7 +60,7 @@ export interface MenuState<T = unknown> extends ListState<T> {
45
60
  export function createMenuState<T = unknown>(
46
61
  props: MaybeAccessor<MenuStateProps<T>>,
47
62
  ): MenuState<T> {
48
- const getProps = () => access(props);
63
+ const getProps = propsAccessor(props);
49
64
 
50
65
  // Menus default to action-only items, but can opt into single or multiple selection.
51
66
  const listState = createListState<T>({
@@ -134,15 +149,51 @@ export interface MenuTriggerState {
134
149
  setPoint(point: { x: number; y: number }): void;
135
150
  }
136
151
 
152
+ export interface RootMenuTriggerState extends MenuTriggerState {
153
+ /** Opens a submenu tied to a menu item at a level. */
154
+ openSubmenu(triggerKey: Key, level: number): void;
155
+ /** Closes a submenu tied to a menu item at a level. */
156
+ closeSubmenu(triggerKey: Key, level: number): void;
157
+ /**
158
+ * Open submenu trigger keys. The index matches the submenu level.
159
+ */
160
+ readonly expandedKeysStack: () => Key[];
161
+ }
162
+
137
163
  /**
138
164
  * Creates state for a menu trigger. Mirrors `@react-stately/menu` `useMenuTriggerState`:
139
- * overlay open state plus the focus strategy passed through to `createMenu`.
165
+ * overlay open state, the focus strategy passed through to `createMenu`, and the
166
+ * expanded submenu stack.
140
167
  */
141
168
  export function createMenuTriggerState(
142
169
  props: MaybeAccessor<MenuTriggerProps> = {},
143
- ): MenuTriggerState {
170
+ ): RootMenuTriggerState {
144
171
  const overlay = createOverlayTriggerState(props);
145
172
  const [focusStrategy, setFocusStrategy] = createInternalSignal<"first" | "last" | null>(null);
173
+ const [expandedKeysStack, setExpandedKeysStack] = createInternalSignal<Key[]>([]);
174
+
175
+ const openSubmenu = (triggerKey: Key, level: number) => {
176
+ setExpandedKeysStack((oldStack) => {
177
+ if (level > oldStack.length) {
178
+ return oldStack;
179
+ }
180
+ return [...oldStack.slice(0, level), triggerKey];
181
+ });
182
+ };
183
+
184
+ const closeSubmenu = (triggerKey: Key, level: number) => {
185
+ setExpandedKeysStack((oldStack) => {
186
+ if (oldStack[level] !== triggerKey) {
187
+ return oldStack;
188
+ }
189
+ return oldStack.slice(0, level);
190
+ });
191
+ };
192
+
193
+ const close = () => {
194
+ setExpandedKeysStack([]);
195
+ overlay.close();
196
+ };
146
197
 
147
198
  return {
148
199
  ...overlay,
@@ -156,5 +207,84 @@ export function createMenuTriggerState(
156
207
  setFocusStrategy(strategy);
157
208
  overlay.toggle();
158
209
  },
210
+ close,
211
+ expandedKeysStack,
212
+ openSubmenu,
213
+ closeSubmenu,
214
+ };
215
+ }
216
+
217
+ export interface SubmenuTriggerProps {
218
+ /** Key of the trigger item. */
219
+ triggerKey: Key;
220
+ }
221
+
222
+ export interface SubmenuTriggerState {
223
+ /** Whether the submenu is currently open. */
224
+ readonly isOpen: () => boolean;
225
+ /** Controls which item will be auto focused when the submenu opens. */
226
+ readonly focusStrategy: () => "first" | "last" | null;
227
+ /** The level of the submenu, frozen when the state is created. */
228
+ readonly submenuLevel: number;
229
+ /** Opens the submenu. */
230
+ open(focusStrategy?: "first" | "last" | null): void;
231
+ /** Closes the submenu. */
232
+ close(): void;
233
+ /** Closes the root menu and every submenu in the tree. */
234
+ closeAll(): void;
235
+ /** Toggles the submenu. */
236
+ toggle(focusStrategy?: "first" | "last" | null): void;
237
+ /** @private Placeholder so this state can sit where an overlay trigger state sits. */
238
+ setOpen(): void;
239
+ /** Submenus do not track a cursor point. */
240
+ readonly point: null;
241
+ /** @private */
242
+ setPoint(): void;
243
+ }
244
+
245
+ /**
246
+ * Creates state for a submenu trigger. Mirrors `@react-stately/menu`
247
+ * `useSubmenuTriggerState`: open state is the root stack entry at this level.
248
+ */
249
+ export function createSubmenuTriggerState(
250
+ props: MaybeAccessor<SubmenuTriggerProps>,
251
+ state: RootMenuTriggerState,
252
+ ): SubmenuTriggerState {
253
+ const getProps = propsAccessor(props);
254
+ const submenuLevel = state.expandedKeysStack().length;
255
+ const [focusStrategy, setFocusStrategy] = createInternalSignal<"first" | "last" | null>(null);
256
+
257
+ const isOpen = () => state.expandedKeysStack()[submenuLevel] === getProps().triggerKey;
258
+
259
+ const open = (strategy?: "first" | "last" | null) => {
260
+ setFocusStrategy(strategy ?? null);
261
+ state.openSubmenu(getProps().triggerKey, submenuLevel);
262
+ };
263
+
264
+ const close = () => {
265
+ setFocusStrategy(null);
266
+ state.closeSubmenu(getProps().triggerKey, submenuLevel);
267
+ };
268
+
269
+ const toggle = (strategy?: "first" | "last" | null) => {
270
+ setFocusStrategy(strategy ?? null);
271
+ if (isOpen()) {
272
+ close();
273
+ } else {
274
+ open(strategy);
275
+ }
276
+ };
277
+
278
+ return {
279
+ focusStrategy,
280
+ isOpen,
281
+ open,
282
+ close,
283
+ closeAll: () => state.close(),
284
+ submenuLevel,
285
+ setOpen() {},
286
+ toggle,
287
+ point: null,
288
+ setPoint() {},
159
289
  };
160
290
  }
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { createMemo } from "solid-js";
24
24
  import type { Accessor } from "solid-js";
25
- import { access, createInternalSignal, readNow, type MaybeAccessor } from "../utils";
25
+ import { createInternalSignal, propsAccessor, readNow, type MaybeAccessor } from "../utils";
26
26
  import type {
27
27
  Collection,
28
28
  DisabledBehavior,
@@ -117,7 +117,7 @@ export interface SelectionPressEvent {
117
117
  export function createSelectionState(
118
118
  props: MaybeAccessor<SelectionStateProps> = {},
119
119
  ): SelectionState {
120
- const getProps = () => access(props);
120
+ const getProps = propsAccessor(props);
121
121
 
122
122
  // Selection behavior state
123
123
  const [internalBehavior, setInternalBehavior] = createInternalSignal<SelectionBehavior>("toggle");
@@ -46,10 +46,14 @@ export {
46
46
  export {
47
47
  createMenuState,
48
48
  createMenuTriggerState,
49
+ createSubmenuTriggerState,
49
50
  type MenuStateProps,
50
51
  type MenuState,
51
52
  type MenuTriggerType,
52
53
  type MenuTriggerProps,
53
54
  type MenuTriggerStateProps,
54
55
  type MenuTriggerState,
56
+ type RootMenuTriggerState,
57
+ type SubmenuTriggerProps,
58
+ type SubmenuTriggerState,
55
59
  } from "./createMenuState";
@@ -45,6 +45,8 @@ export interface ColorAreaStateOptions {
45
45
  export interface ColorAreaState {
46
46
  /** The current color value. */
47
47
  readonly value: Color;
48
+ /** The color a native form reset restores. */
49
+ readonly defaultValue: Color;
48
50
  /** The X axis channel. */
49
51
  readonly xChannel: ColorChannel;
50
52
  /** The Y axis channel. */
@@ -72,6 +74,8 @@ export interface ColorAreaState {
72
74
  setXValue(value: number): void;
73
75
  /** Set the Y channel value. */
74
76
  setYValue(value: number): void;
77
+ /** Replace the color. */
78
+ setValue(value: Color | string): void;
75
79
  /** Set color from a point (0-1, 0-1). */
76
80
  setColorFromPoint(x: number, y: number): void;
77
81
  /** Get the thumb position as percentages. */
@@ -104,6 +108,13 @@ export function createColorAreaState(options: Accessor<ColorAreaStateOptions>):
104
108
 
105
109
  // Internal value state
106
110
  const [internalValue, setInternalValue] = createInternalSignal<Color | null>(null);
111
+ // Pin useColorAreaState returns the first controlled color as defaultValue
112
+ // so a native form reset restores that color. The built-in red is only the
113
+ // uncontrolled fallback when no defaultValue was passed.
114
+ const firstOptions = getOptions();
115
+ const controlledInitial =
116
+ firstOptions.value !== undefined ? normalizeValue(firstOptions.value) : undefined;
117
+ const initialValue = normalizeValue(firstOptions.defaultValue ?? "#ff0000");
107
118
  const [isDragging, setIsDragging] = createInternalSignal(false);
108
119
 
109
120
  // Initialize internal value
@@ -182,6 +193,10 @@ export function createColorAreaState(options: Accessor<ColorAreaStateOptions>):
182
193
  // Get Y value
183
194
  const getYValue = () => value().getChannelValue(yChannel());
184
195
 
196
+ const setValue = (newValue: Color | string) => {
197
+ updateValue(normalizeValue(newValue));
198
+ };
199
+
185
200
  // Set X value
186
201
  const setXValue = (newValue: number) => {
187
202
  const range = xRange();
@@ -283,6 +298,9 @@ export function createColorAreaState(options: Accessor<ColorAreaStateOptions>):
283
298
  get value() {
284
299
  return value();
285
300
  },
301
+ get defaultValue() {
302
+ return controlledInitial ?? initialValue;
303
+ },
286
304
  get xChannel() {
287
305
  return xChannel();
288
306
  },
@@ -314,6 +332,7 @@ export function createColorAreaState(options: Accessor<ColorAreaStateOptions>):
314
332
  getYValue,
315
333
  setXValue,
316
334
  setYValue,
335
+ setValue,
317
336
  setColorFromPoint,
318
337
  getThumbPosition,
319
338
  incrementX,
@@ -57,6 +57,8 @@ export interface ColorFieldState {
57
57
  readonly value: Color | null;
58
58
  /** The color used for channel calculations. */
59
59
  readonly colorValue: Color;
60
+ /** The color a native form reset restores. */
61
+ readonly defaultColorValue: Color | null;
60
62
  /** The current input text. */
61
63
  readonly inputValue: string;
62
64
  /** The numeric value exposed by channel fields. */
@@ -212,6 +214,15 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
212
214
  const getOptions = () => options();
213
215
  const initialOptions = getOptions();
214
216
  const initialValue = getDefaultValue(initialOptions);
217
+ // Form reset restores the first defaultValue, or the first controlled color
218
+ // when that default was omitted. An explicit null default falls through.
219
+ const initialDefault =
220
+ initialOptions.defaultValue !== undefined
221
+ ? normalizeNullableColor(initialOptions.defaultValue)
222
+ : undefined;
223
+ const firstColor =
224
+ initialOptions.value !== undefined ? normalizeNullableColor(initialOptions.value) : undefined;
225
+ const defaultColorValue = initialDefault ?? firstColor ?? null;
215
226
 
216
227
  const [internalValue, setInternalValue] = createInternalSignal<Color | null>(initialValue);
217
228
  const [inputValue, setInputValueInternal] = createInternalSignal(
@@ -224,6 +235,9 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
224
235
  ),
225
236
  );
226
237
  const [invalidInput, setInvalidInput] = createInternalSignal(false);
238
+ // Formatted channel text may round the accepted number. Only user edits
239
+ // should parse that text back into a new numerical value.
240
+ let isChannelInputEdited = false;
227
241
 
228
242
  const channel = createMemo(() => getOptions().channel);
229
243
  const colorSpace = createMemo(() => getOptions().colorSpace);
@@ -249,6 +263,7 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
249
263
  createEffect(
250
264
  () => formatCurrentValue(),
251
265
  (formatted) => {
266
+ isChannelInputEdited = false;
252
267
  setInputValueInternal(formatted);
253
268
  setInvalidInput(false);
254
269
  },
@@ -262,11 +277,13 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
262
277
  }
263
278
 
264
279
  opts.onChange?.(newColor);
280
+ isChannelInputEdited = false;
265
281
  setInputValueInternal(formatCurrentValue(newColor));
266
282
  setInvalidInput(false);
267
283
  };
268
284
 
269
285
  const setInputValue = (text: string) => {
286
+ isChannelInputEdited = true;
270
287
  setInputValueInternal(text);
271
288
  setInvalidInput(false);
272
289
  };
@@ -275,45 +292,74 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
275
292
  updateValue(newColor);
276
293
  };
277
294
 
295
+ // Methods can run before Solid's value memo observes an earlier write.
296
+ const currentValue = () => {
297
+ const opts = getOptions();
298
+ return opts.value !== undefined ? normalizeNullableColor(opts.value) : readNow(internalValue);
299
+ };
300
+
278
301
  const commit = () => {
279
302
  const text = readNow(inputValue).trim();
280
303
  const opts = getOptions();
281
304
  const chan = channel();
305
+ const current = currentValue();
282
306
 
283
307
  if (!text) {
284
- updateValue(null);
285
- return;
286
- }
287
-
288
- if (chan) {
289
- const displayColor = colorValue();
308
+ if (current !== null) updateValue(null);
309
+ } else if (chan) {
310
+ const displayColor = getDisplayColor(current, colorSpace());
290
311
  const rawValue = parseChannelValue(text, displayColor, chan);
291
- if (rawValue == null) {
292
- setInputValueInternal(formatCurrentValue());
293
- setInvalidInput(false);
294
- return;
312
+ if (isChannelInputEdited && rawValue !== null) {
313
+ const next = displayColor.withChannelValue(chan, rawValue);
314
+ if (current === null || next.getChannelValue(chan) !== displayColor.getChannelValue(chan)) {
315
+ updateValue(next);
316
+ }
317
+ }
318
+ } else {
319
+ try {
320
+ const next = parseHexValue(text, opts.colorFormat);
321
+ // Whole hex edits compare RGB; hexa also edits alpha. This is not
322
+ // the equality policy for channel edits or the public color setter.
323
+ if (
324
+ current === null ||
325
+ next.toHexInt() !== current.toHexInt() ||
326
+ (opts.colorFormat === "hexa" &&
327
+ next.getChannelValue("alpha") !== current.getChannelValue("alpha"))
328
+ ) {
329
+ updateValue(next);
330
+ }
331
+ } catch {
332
+ // Invalid text rolls back to the accepted color below.
295
333
  }
296
-
297
- updateValue(displayColor.withChannelValue(chan, rawValue));
298
- return;
299
334
  }
300
335
 
336
+ // Read after onChange: a controlled parent may accept or refuse the
337
+ // request. Later signal acceptance is handled by the formatting effect.
338
+ isChannelInputEdited = false;
339
+ setInputValueInternal(formatCurrentValue(currentValue()));
340
+ setInvalidInput(false);
341
+ };
342
+
343
+ const incrementHex = (amount: number) => {
344
+ // Step the text being edited. A missing or unparseable hex starts at black,
345
+ // matching useColorFieldState's addColorValue(parsedValue, step).
346
+ const text = readNow(inputValue);
347
+ let parsed: Color | null = null;
301
348
  try {
302
- updateValue(parseHexValue(text, opts.colorFormat));
349
+ parsed = parseColor(text.startsWith("#") ? text : `#${text}`);
303
350
  } catch {
304
- setInputValueInternal(formatCurrentValue());
305
- setInvalidInput(false);
351
+ parsed = null;
306
352
  }
307
- };
308
353
 
309
- const incrementHex = (amount: number) => {
310
- const currentColor = value();
311
- if (!currentColor) {
354
+ const base = parsed ?? hexIntToColor(0);
355
+ const nextColor = hexIntToColor(base.toFormat("rgb").toHexInt() + amount);
356
+ const current = value();
357
+ if (current && current.toFormat("rgb").toHexInt() === nextColor.toHexInt()) {
312
358
  return;
313
359
  }
314
360
 
315
- const newColor = hexIntToColor(currentColor.toFormat("rgb").toHexInt() + amount);
316
- updateValue(getOptions().colorFormat ? newColor.toFormat(getOptions().colorFormat!) : newColor);
361
+ const format = getOptions().colorFormat;
362
+ updateValue(format ? nextColor.toFormat(format) : nextColor);
317
363
  };
318
364
 
319
365
  const updateChannel = (amount: number | "min" | "max") => {
@@ -322,17 +368,20 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
322
368
  return;
323
369
  }
324
370
 
325
- const displayColor = colorValue();
371
+ const current = currentValue();
372
+ const displayColor = getDisplayColor(current, colorSpace());
326
373
  const range = displayColor.getChannelRange(chan);
327
- const currentValue = displayColor.getChannelValue(chan);
374
+ const currentChannel = displayColor.getChannelValue(chan);
328
375
  const nextValue =
329
376
  amount === "min"
330
377
  ? range.minValue
331
378
  : amount === "max"
332
379
  ? range.maxValue
333
- : Math.min(range.maxValue, Math.max(range.minValue, currentValue + amount));
380
+ : Math.min(range.maxValue, Math.max(range.minValue, currentChannel + amount));
334
381
 
335
- updateValue(displayColor.withChannelValue(chan, nextValue));
382
+ const next = displayColor.withChannelValue(chan, nextValue);
383
+ if (current !== null && next.getChannelValue(chan) === currentChannel) return;
384
+ updateValue(next);
336
385
  };
337
386
 
338
387
  const increment = () => {
@@ -397,6 +446,7 @@ export function createColorFieldState(options: Accessor<ColorFieldStateOptions>)
397
446
  get colorValue() {
398
447
  return colorValue();
399
448
  },
449
+ defaultColorValue,
400
450
  get inputValue() {
401
451
  return inputValue();
402
452
  },