Skip to content

Commit 9914af0

Browse files
author
Roy Lin
committed
feat: add native number field stepping
1 parent 30255e3 commit 9914af0

23 files changed

Lines changed: 789 additions & 108 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ object graph at the host boundary.
2727
background inertness, nested focus containment/restoration, typed anchored
2828
overlay placement, inherited locale/direction, locale-aware collation and
2929
filtering, decimal and percent parsing with localized NumberField
30-
model/display conversion, decimal/percent/date/time formatting, versioned
30+
model/display conversion, minimum-anchored step normalization, grouped
31+
decrement/input/increment controls, portable ArrowUp/ArrowDown stepping,
32+
decimal/percent/date/time formatting, versioned
3133
capability reporting, shared native event-source state machines, and
3234
accessibility conformance checks. Versioned
3335
native-input manifests expand every role marked
@@ -44,7 +46,8 @@ object graph at the host boundary.
4446
role-edge input parity, real-platform layout-aware page-navigation fixtures,
4547
IME/dead-key conformance, measured overlay collision/arrow conformance and
4648
scroll locking, remaining native focus conformance, message catalogs,
47-
currency/unit parsing and formatting, and date-range/time-zone formatting are
49+
currency/unit parsing and formatting, date-range/time-zone formatting,
50+
continuous NumberField press stepping, and NumberField wheel stepping are
4851
still in progress.
4952
- `.rsx` component source modules with imports, local Rust types, hook
5053
registrations, Rust selector/reducer expressions, and a final `rsx!(...)`

docs/architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,15 @@ uses the same public collator filter. Native number text fields use the cached
472472
parser and formatter for localized display, range/step normalization, and
473473
model-space change events, so interactive behavior and application-level
474474
locale rules cannot drift.
475+
NumberField is represented as a native group with separate decrement button,
476+
numeric text input, and increment button nodes. The semantic hook computes
477+
button action values and `canIncrement`/`canDecrement` from the same
478+
minimum-anchored step grid used by runtime ArrowUp/ArrowDown handling. The
479+
shared interaction profile marks these keys as handled on AppKit, GTK4, and
480+
WinUI so a toolkit-native numeric control cannot apply a second increment.
481+
Read-only and disabled fields retain focus semantics but do not produce step
482+
changes. Continuous press and wheel stepping are intentionally separate
483+
remaining interaction contracts.
475484
AppKit keeps logical item identity separate from the collection selection
476485
target: a row button is registered as the ListBoxItem or TreeItem responder,
477486
while activation reports the selected value to the owning collection.

docs/react-aria-native.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,16 @@ The first shared interaction milestone is available in the portable runtime:
272272
covers localized short through full styles, seconds, calendar,
273273
numbering-system, and hour-cycle locale extensions. Collection typeahead
274274
reuses the public collator filter.
275+
- `use_number_field` exposes separate group, input, increment-button, and
276+
decrement-button prop contracts. `UiNumberField` projects that anatomy as a
277+
native group instead of collapsing it into one text field. Buttons are
278+
excluded from sequential focus, carry the next model-space value, use
279+
field-aware accessible labels (or explicit `incrementAriaLabel` and
280+
`decrementAriaLabel` overrides), and disable at step boundaries or for
281+
disabled/read-only fields. ArrowUp and ArrowDown use the same
282+
minimum-anchored step algorithm, including decimal-noise cleanup. AppKit,
283+
GTK4, and WinUI claim those keys before the toolkit default runs, preventing
284+
GTK4 `SpinButton` from emitting a duplicate change.
275285
- Native IR capabilities are versioned. Every host exposes a feature manifest
276286
with unsupported, portable, or native support levels, role-specific
277287
overrides, and auditable capability issues. Protocol render responses carry
@@ -311,6 +321,7 @@ existence of a platform object:
311321
| Complete press lifecycle | Button, disclosure summary, link, image-map area, ListBoxItem, and TreeItem on AppKit, GTK4, and WinUI; WinUI menu items also use the complete lifecycle. |
312322
| Long press | Shared AppKit, GTK4, and WinUI press sources emit start/end and recognize terminal long press after the configured threshold. `NSTimer`, GTK main-loop timeout, and `DispatcherQueueTimer` provide threshold-time delivery, and release-time evaluation is the fallback. |
313323
| Move | AppKit mouse/pen drag events, GTK4 `GestureDrag`, and WinUI mouse/touch/pen pointer capture use one incremental move state machine. All three normalize Arrow keys to a complete keyboard lifecycle and prevent the underlying native default. |
324+
| NumberField stepping | The shared runtime maps ArrowUp/ArrowDown to model-space `Change` events on the next minimum-anchored step boundary. AppKit, GTK4, and WinUI suppress their underlying key default for marked NumberField inputs. Built-in decrement and increment buttons expose the same next values and boundary/read-only state through native Button controls. Continuous pointer-hold stepping, wheel stepping, automatic localized button messages, and live value announcements remain incomplete. |
314325
| Native menu activation | AppKit and GTK4 menu items emit terminal press only because their menu models do not expose a mounted generic view event source. |
315326
| Hover and typed modality | View-backed widgets; explicit exceptions are reported for AppKit non-view wrappers/items, GTK4 menu items, and the WinUI window wrapper. |
316327
| Focus within | Portable runtime routing on AppKit, GTK4, WinUI, and headless hosts. Native blur/focus batches are linked with `relatedTarget`; direct focus callbacks remain target-only while focus-within callbacks run only when a subtree boundary is crossed. |
@@ -390,6 +401,7 @@ props:
390401
| P1 | Event propagation | Add platform-run conformance fixtures for conditional `Stop`/`Continue` across nested native controls. |
391402
| P1 | Focus management | Add platform-run conformance fixtures for post-mount `autoFocus`, nested containment, and restoration. |
392403
| P1 | Collections and selection | Complete IME/dead-key typeahead conformance and add real-platform fixtures for layout-aware page navigation. |
404+
| P1 | NumberField interaction | Add continuous press-and-hold stepping, focused vertical-wheel stepping with horizontal-trackpad rejection, automatic localized stepper labels, input-focus preservation on pointer press, and live value announcements. Group/input/button anatomy, minimum-anchored button stepping, ArrowUp/ArrowDown stepping, decimal-noise cleanup, boundary disabling, and native-default suppression are implemented. |
393405
| P1 | Internationalization | Add message formatting, currency/unit parsing and formatting, and date ranges/time zones. Reusable decimal/percent parsing and formatting, partial-input validation, locale-aware filtering, and localized NumberField model/display conversion now build on inherited locale/direction. |
394406
| P1 | Accessibility conformance | Complete OS accessibility API projection, relationships, live regions, value announcements, and role-specific native adapter coverage. |
395407
| P2 | Overlays | Complete measured boundary-driven collision and arrow projection, native scroll locking, configurable outside-interaction filters, multi-window layer coordination, and real-platform positioning conformance fixtures. |

docs/roadmap.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ The following foundations are implemented and are not roadmap placeholders:
7777
transactional runtime projection for interaction and typed state variants
7878
- inherited BCP 47 locale/direction, thread-safe ICU4X collation and filtering,
7979
localized decimal/percent parsing and partial-input validation, NumberField
80-
model/display normalization, and decimal/percent/date/time formatting
80+
model/display normalization, grouped native stepper controls,
81+
minimum-anchored button and ArrowUp/ArrowDown stepping with platform-default
82+
suppression, and decimal/percent/date/time formatting
8183
- an activation-ordered mounted overlay stack with topmost Escape/outside
8284
dismissal, modal background inertness, portaled foreground layers, and
8385
shared focus containment, autofocus, and restoration

docs/rsx.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,31 @@ card parts are under `src/rsx_ui/components/card/`, checkbox parts are under
14771477
`src/rsx_ui/components/file/`, and layout semantics are under
14781478
`src/rsx_ui/components/layout/`.
14791479

1480+
`UiNumberField` renders a label plus a native control group containing
1481+
decrement, numeric-input, and increment controls. Button presses and
1482+
ArrowUp/ArrowDown deliver the next canonical model-space value to `onChange`;
1483+
step boundaries are anchored at `minValue`. Percent fields default to a `0.01`
1484+
step.
1485+
1486+
```xml
1487+
<UiNumberField
1488+
key="tax-rate"
1489+
label="Tax rate"
1490+
valueNumber={state.taxRate}
1491+
minValue="0"
1492+
maxValue="1"
1493+
formatStyle="percent"
1494+
incrementAriaLabel="Increase tax rate"
1495+
decrementAriaLabel="Decrease tax rate"
1496+
onChange={setTaxRate}
1497+
/>
1498+
```
1499+
1500+
The accessible-label overrides are optional. Without them, the built-in
1501+
English labels include the field label. Applications should provide localized
1502+
overrides until automatic message catalogs are implemented. Continuous
1503+
press-and-hold and wheel stepping are not yet part of this contract.
1504+
14801505
- `UiButton`
14811506
- `UiBadge`
14821507
- `UiAutocomplete`

src/appkit_native/interaction.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ impl AppKitNativeSurface {
199199
.push(NativeEvent::new(node, kind).value(key).context(context));
200200
return false;
201201
};
202+
let number_field_step_handled = registration
203+
.profile
204+
.handles_number_field_step_key(kind, &key);
202205
let mut pending =
203206
if kind == NativeEventKind::KeyDown && registration.profile.tracks_movement() {
204207
keyboard_move_events(node, &key, context)
@@ -227,7 +230,7 @@ impl AppKitNativeSurface {
227230
.borrow_mut()
228231
.insert(node, activation_context);
229232
}
230-
movement_handled
233+
movement_handled || number_field_step_handled
231234
}
232235

233236
fn begin_pointer_press(&self, event: &NSEvent) {

src/compiler.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2295,7 +2295,12 @@ fn props_scope_value(props: &CompiledProps) -> JsonValue {
22952295
&mut scope,
22962296
"numberField",
22972297
number_field,
2298-
&["numberFieldProps", "numberFieldInputProps"],
2298+
&[
2299+
"numberFieldProps",
2300+
"numberFieldInputProps",
2301+
"incrementButtonProps",
2302+
"decrementButtonProps",
2303+
],
22992304
&[
23002305
"label",
23012306
"valueNumber",
@@ -2310,6 +2315,8 @@ fn props_scope_value(props: &CompiledProps) -> JsonValue {
23102315
"isRequired",
23112316
"isInvalid",
23122317
"isReadOnly",
2318+
"canIncrement",
2319+
"canDecrement",
23132320
],
23142321
);
23152322
}
@@ -4165,6 +4172,14 @@ fn number_field_scope_value(props: &CompiledProps) -> GuiResult<JsonValue> {
41654172
non_empty_prop_action(props.events.get("onChange"))
41664173
.or_else(|| non_empty_prop_action(props.events.get("onInput"))),
41674174
)
4175+
.increment_aria_label(non_empty_attribute(
4176+
props,
4177+
&["incrementAriaLabel", "increment-aria-label"],
4178+
))
4179+
.decrement_aria_label(non_empty_attribute(
4180+
props,
4181+
&["decrementAriaLabel", "decrement-aria-label"],
4182+
))
41684183
.disabled(props.is_disabled)
41694184
.required(props.is_required)
41704185
.invalid(props.is_invalid)

src/event/press.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
66
use crate::event::{NativeEvent, NativeEventKind};
77
use crate::host::HostNodeId;
88
use crate::input::{NativeEventContext, NativeInputModality};
9-
use crate::native::NativeRole;
9+
use crate::native::{is_number_input_type, NativeRole, NUMBER_FIELD_INPUT_METADATA_KEY};
1010
use crate::platform::{NativeWidgetBlueprint, NativeWidgetSetter};
1111

1212
use super::move_interaction::PointerMoveState;
@@ -227,6 +227,8 @@ pub(crate) struct NativeInteractionProfile {
227227
has_terminal_event: bool,
228228
has_long_press_event: bool,
229229
has_collection_action: bool,
230+
number_field_input: bool,
231+
number_input: bool,
230232
enabled: bool,
231233
long_press_threshold: Duration,
232234
}
@@ -243,6 +245,11 @@ impl NativeInteractionProfile {
243245
&["onLongPressStart", "onLongPressEnd", "onLongPress"],
244246
);
245247
let has_collection_action = has_collection_action(blueprint);
248+
let number_field_input = blueprint
249+
.metadata
250+
.get(NUMBER_FIELD_INPUT_METADATA_KEY)
251+
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
252+
let number_input = is_number_input_type(blueprint.control_state.input_type.as_deref());
246253
let event_subscriptions = NativeInteractionSubscriptions::from_events(
247254
&blueprint.events,
248255
has_action || has_collection_action,
@@ -261,6 +268,8 @@ impl NativeInteractionProfile {
261268
has_terminal_event,
262269
has_long_press_event,
263270
has_collection_action,
271+
number_field_input,
272+
number_input,
264273
enabled: !blueprint.control_state.disabled,
265274
long_press_threshold: long_press_threshold(&blueprint.metadata),
266275
};
@@ -293,10 +302,16 @@ impl NativeInteractionProfile {
293302
.get(crate::selection::COLLECTION_ACTION_METADATA_KEY)
294303
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
295304
self.long_press_threshold = long_press_threshold(metadata);
305+
self.number_field_input = metadata
306+
.get(NUMBER_FIELD_INPUT_METADATA_KEY)
307+
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
296308
self.overlay_subscriptions =
297309
NativeInteractionSubscriptions::from_metadata(metadata);
298310
self.refresh_subscriptions();
299311
}
312+
NativeWidgetSetter::SetInputType(input_type) => {
313+
self.number_input = is_number_input_type(input_type.as_deref());
314+
}
300315
NativeWidgetSetter::SetPortableStyle(style) => {
301316
self.style_subscriptions = NativeInteractionSubscriptions::from_style(style);
302317
self.refresh_subscriptions();
@@ -340,6 +355,17 @@ impl NativeInteractionProfile {
340355
self.enabled && self.subscriptions.movement
341356
}
342357

358+
pub(crate) fn handles_number_field_step_key(self, kind: NativeEventKind, key: &str) -> bool {
359+
self.enabled
360+
&& self.number_field_input
361+
&& self.number_input
362+
&& kind == NativeEventKind::KeyDown
363+
&& matches!(
364+
super::native_key_value(key).as_str(),
365+
"ArrowUp" | "ArrowDown"
366+
)
367+
}
368+
343369
fn refresh_subscriptions(&mut self) {
344370
self.event_subscriptions.terminal_press =
345371
self.has_action || self.has_terminal_event || self.has_collection_action;
@@ -1169,6 +1195,30 @@ mod tests {
11691195
assert!(profile.normalizes_keyboard_press());
11701196
}
11711197

1198+
#[test]
1199+
fn number_field_profiles_claim_only_the_portable_arrow_step_keys() {
1200+
let element = NativeElement::new("quantity", NativeRole::TextField).with_props(
1201+
NativeProps::new()
1202+
.input_type("number")
1203+
.metadata(NUMBER_FIELD_INPUT_METADATA_KEY, "true"),
1204+
);
1205+
let blueprint = AppKitAdapter.blueprint(&element);
1206+
let mut profile = NativeInteractionProfile::from_blueprint(&blueprint);
1207+
1208+
assert!(profile.handles_number_field_step_key(NativeEventKind::KeyDown, "ArrowUp"));
1209+
assert!(profile.handles_number_field_step_key(NativeEventKind::KeyDown, "Down"));
1210+
assert!(!profile.handles_number_field_step_key(NativeEventKind::KeyUp, "ArrowUp"));
1211+
assert!(!profile.handles_number_field_step_key(NativeEventKind::KeyDown, "ArrowLeft"));
1212+
1213+
profile.apply_setter(&NativeWidgetSetter::SetInputType(Some("text".to_string())));
1214+
assert!(!profile.handles_number_field_step_key(NativeEventKind::KeyDown, "ArrowUp"));
1215+
profile.apply_setter(&NativeWidgetSetter::SetInputType(Some(
1216+
"number".to_string(),
1217+
)));
1218+
profile.apply_setter(&NativeWidgetSetter::SetMetadata(BTreeMap::new()));
1219+
assert!(!profile.handles_number_field_step_key(NativeEventKind::KeyDown, "ArrowUp"));
1220+
}
1221+
11721222
#[test]
11731223
fn collection_action_marker_subscribes_items_without_copying_the_callback() {
11741224
let element = NativeElement::new("ada", NativeRole::ListBoxItem).with_props(

src/gtk4_native/interaction.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ fn register_key_events(
136136
let repeat = down_presses.borrow().target_for_key(&key).is_some();
137137
let context = keyboard_context(modifiers, repeat);
138138
let current = down_registration.get();
139+
let number_field_step_handled = current
140+
.profile
141+
.handles_number_field_step_key(NativeEventKind::KeyDown, &key);
139142
if current.native_button {
140143
remember_activation_context(&down_contexts, id, context);
141144
}
@@ -154,7 +157,7 @@ fn register_key_events(
154157
current.profile.subscriptions.tracks_press(),
155158
));
156159
push_events(&down_events, &down_suppressed, pending);
157-
if movement_handled {
160+
if movement_handled || number_field_step_handled {
158161
gtk::glib::Propagation::Stop
159162
} else {
160163
gtk::glib::Propagation::Proceed

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,8 @@ pub use semantic_ui::{
404404
};
405405
pub use semantic_ui::{use_form, use_form_value, FormProps, UseFormProps, UseFormResult};
406406
pub use semantic_ui::{
407-
use_number_field, use_number_field_value, NumberFieldInputProps, NumberFieldProps,
408-
UseNumberFieldProps, UseNumberFieldResult,
407+
use_number_field, use_number_field_value, NumberFieldButtonProps, NumberFieldInputProps,
408+
NumberFieldProps, UseNumberFieldProps, UseNumberFieldResult,
409409
};
410410
pub use semantic_ui::{
411411
use_slider_fill, use_slider_fill_value, use_slider_output, use_slider_output_value,

0 commit comments

Comments
 (0)