@uniflowed/ui 0.0.0-alpha.9 → 0.2.0

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 (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
package/menubar.js ADDED
@@ -0,0 +1,295 @@
1
+ // @flow
2
+ //
3
+ // A row of menus that behaves as one control: File, Edit, View.
4
+ //
5
+ // It is not a row of `Menu.Root`s, and the reason is that the *set* has a
6
+ // keyboard map of its own — one that only exists because the menus are next to
7
+ // each other:
8
+ //
9
+ // * The whole bar takes **one** stop in the page's tab order, so `Tab` past an
10
+ // application menu is one press rather than six.
11
+ // * `ArrowLeft` / `ArrowRight` move between the top-level menus, mirrored in a
12
+ // right-to-left page because they are the inline axis.
13
+ // * `ArrowDown` opens the menu under the cursor and lands on its first item;
14
+ // `ArrowUp` opens it onto its last, which is the same argument `Menu.Trigger`
15
+ // makes about the destructive command at the bottom of a long menu.
16
+ // * `Home` / `End` go to the first and last menu.
17
+ // * And the part that is always missing: `ArrowLeft` / `ArrowRight` **while a
18
+ // menu is open** close it and open the adjacent one, so a reader can walk
19
+ // File → Edit → View without pressing `Escape` between them. A menubar
20
+ // without it makes the arrow keys mean two different things depending on
21
+ // whether a menu happens to be showing.
22
+ // * `Escape` closes the open menu and leaves focus on its trigger, in the bar,
23
+ // which `Menu.Body` already does — a menubar trigger is the menu's trigger.
24
+ //
25
+ // Everything inside a menu is `menu.js`: the arrow keys within it, typeahead,
26
+ // submenus, the checkable items and the roving tab stop of the menu itself.
27
+ // `Menubar.Body` is `Menu.Body` itself rather than a wrapper around it, because
28
+ // a bar's menu *is* a root menu — it hangs off a button and drops from it — and
29
+ // `Menu.Body` already places a root menu on the bottom, aligned to the start. A
30
+ // wrapper would have been a second component with the same defaults written out
31
+ // again, and a second place for them to drift.
32
+ //
33
+ // # How the bar finds its own triggers
34
+ //
35
+ // `internal/roving-focus.js`'s `itemsOf(container, item, owner)` takes the owner
36
+ // selector as a parameter for exactly this: a set says what owns it. A menu
37
+ // passes `[role="menu"]`, and a menubar has to pass **both** — an open menu is a
38
+ // DOM descendant of the bar, and its items are `role="menuitem"` too, so a bar
39
+ // that only asked "menu items inside me" would step into the open menu's items
40
+ // with `ArrowRight`. Naming the two owners makes `closest` stop at the menu for
41
+ // an item inside one and at the bar for a trigger, which is the distinction, and
42
+ // it is settled by the roles the two containers already carry.
43
+ //
44
+ // Turning a trigger *element* back into the menu it opens is the one thing the
45
+ // roles cannot say, and `data-uf-menubar-value` is that and nothing more. The
46
+ // alternative is a registry of refs, which `roving-focus.js` explains at length
47
+ // is a second opinion about document order.
48
+ //
49
+ // # Which menu is open is the bar's state
50
+ //
51
+ // One value, `string | null`, rather than a boolean per menu. Two menus open at
52
+ // once is the state this component exists to prevent, and a per-menu boolean is
53
+ // a set of booleans somebody has to keep exclusive; `accordion.js` makes the
54
+ // same argument about `single`. It is also what makes "close this one and open
55
+ // the next" a single assignment rather than a pair of them that render twice.
56
+
57
+ "use client";
58
+
59
+ import * as React from "@uniflowed/react";
60
+ import {
61
+ createContext,
62
+ useCallback,
63
+ useContext,
64
+ useMemo,
65
+ useRef,
66
+ useState,
67
+ } from "@uniflowed/react";
68
+
69
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
70
+ import {
71
+ composeHandlers,
72
+ composeRefs,
73
+ withProps,
74
+ withoutComposed,
75
+ } from "./internal/merge-props.js";
76
+ import type { RovingSet } from "./internal/roving-focus.js";
77
+ import {
78
+ directionOf,
79
+ indexOfActive,
80
+ itemsOf,
81
+ movementFor,
82
+ moveTo,
83
+ useFirstItem,
84
+ } from "./internal/roving-focus.js";
85
+ import { MenuLevel, useMenu, useTriggerRegistration } from "./internal/menu-tree.js";
86
+
87
+ /**
88
+ * The bar's own items, and the two things that may own one.
89
+ *
90
+ * See the module header: an open menu is inside the bar and its items wear the
91
+ * same role, so the owner selector names both and `closest` settles it.
92
+ */
93
+ const TRIGGERS: RovingSet = {
94
+ item: '[role="menuitem"]',
95
+ owner: '[role="menu"], [role="menubar"]',
96
+ orientation: "horizontal",
97
+ wrap: true,
98
+ skipDisabled: true,
99
+ };
100
+
101
+ type MenubarState = {|
102
+ /** Which menu is showing, by the `value` its `Menubar.Menu` was given. */
103
+ readonly open: string | null,
104
+ readonly setOpen: (value: string | null) => void,
105
+ /** Which trigger holds the bar's single tab stop, or null for "the first". */
106
+ readonly active: string | null,
107
+ readonly setActive: (value: string) => void,
108
+ readonly firstId: string | null,
109
+ |};
110
+
111
+ const MenubarContext: React.Context<MenubarState | null> = createContext(null);
112
+
113
+ /** The `value` of the `Menubar.Menu` a trigger belongs to. */
114
+ const MenubarMenuContext: React.Context<string | null> = createContext(null);
115
+
116
+ hook useMenubar(part: string): MenubarState {
117
+ const state = useContext(MenubarContext);
118
+ if (state == null) {
119
+ throw new Error(`${part} must be rendered inside a Menubar.Root`);
120
+ }
121
+ return state;
122
+ }
123
+
124
+ /**
125
+ * The bar: `role="menubar"`, one tab stop, and the arrows between the menus.
126
+ *
127
+ * `aria-label` is the caller's and matters more here than on most containers —
128
+ * a page with an application menubar and a formatting toolbar has two, and
129
+ * "menu bar" twice tells a reader nothing about which is which.
130
+ */
131
+ export component MenubarRoot(children: renders* MenubarMenu, render?: RenderProp, ...rest: Rest) {
132
+ const barRef = useRef<HTMLElement | null>(null);
133
+ const [open, setOpenValue] = useState<string | null>(null);
134
+ const [active, setActive] = useState<string | null>(null);
135
+ // Only while nothing has been focused or opened. Once a trigger holds the tab
136
+ // stop, asking the document which one comes first is work with no reader.
137
+ const firstId = useFirstItem(barRef, TRIGGERS, active == null);
138
+
139
+ const setOpen = useCallback((value: string | null) => {
140
+ setOpenValue(value);
141
+ if (value != null) {
142
+ setActive(value);
143
+ }
144
+ }, []);
145
+
146
+ const state = useMemo(
147
+ () => ({ open, setOpen, active, setActive, firstId }),
148
+ [open, setOpen, active, firstId],
149
+ );
150
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
151
+ "aria-orientation": "horizontal",
152
+ children,
153
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
154
+ const bar: $FlowFixMe = event.currentTarget;
155
+ const movement = movementFor(event.key, "horizontal", directionOf(bar));
156
+ if (movement == null) {
157
+ return;
158
+ }
159
+ const triggers = itemsOf(bar, TRIGGERS.item, TRIGGERS.owner);
160
+ // With a menu open, focus is on one of *its* items rather than on a
161
+ // trigger, so "where am I in the bar" is the expanded trigger. This
162
+ // is the whole of walking File → Edit → View without pressing Escape.
163
+ const focused = indexOfActive(triggers, bar.ownerDocument?.activeElement);
164
+ const at =
165
+ focused >= 0
166
+ ? focused
167
+ : triggers.findIndex((each) => each.getAttribute("aria-expanded") === "true");
168
+ const next = moveTo(triggers, at, movement, TRIGGERS.wrap, TRIGGERS.skipDisabled);
169
+ if (next == null) {
170
+ return;
171
+ }
172
+ // Claimed before focus moves, or the browser scrolls the page under
173
+ // the trigger that has just taken it; `moveOnKey` says the same.
174
+ event.preventDefault();
175
+ event.stopPropagation();
176
+ const value = next.getAttribute("data-uf-menubar-value");
177
+ if (open != null && value != null) {
178
+ // Swap which menu is showing. Focus lands on the new menu's first
179
+ // item through `Menu.Body`'s own opening effect, so nothing here
180
+ // moves it: focusing the trigger as well would be two focus moves
181
+ // in one commit and the reader would see the second.
182
+ setOpen(value);
183
+ return;
184
+ }
185
+ next.focus();
186
+ if (value != null) {
187
+ setActive(value);
188
+ }
189
+ }),
190
+ // React calls callback refs during commit; keyboard handlers read it later.
191
+ // uf-lint-disable-next-line react-compiler/refs
192
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
193
+ barRef.current = element;
194
+ }),
195
+ role: "menubar",
196
+ });
197
+
198
+ return (
199
+ <MenubarContext.Provider value={state}>
200
+ {render == null ? <div {...props} /> : render(props)}
201
+ </MenubarContext.Provider>
202
+ );
203
+ }
204
+
205
+ /**
206
+ * One menu of the bar, named by the `value` the bar opens and closes it with.
207
+ *
208
+ * Renders no element of its own, for the reason `Menu.Root` gives: a trigger and
209
+ * its body are siblings in whatever layout the caller wrote.
210
+ */
211
+ export component MenubarMenu(children: React.Node, value: string) {
212
+ const bar = useMenubar("Menubar.Menu");
213
+ const setOpen = bar.setOpen;
214
+ const onOpenChange = useCallback(
215
+ (next: boolean) => setOpen(next ? value : null),
216
+ [setOpen, value],
217
+ );
218
+
219
+ return (
220
+ <MenubarMenuContext.Provider value={value}>
221
+ <MenuLevel
222
+ defaultOpen={false}
223
+ onOpenChange={onOpenChange}
224
+ open={bar.open === value}
225
+ parent={null}
226
+ >
227
+ {children}
228
+ </MenuLevel>
229
+ </MenubarMenuContext.Provider>
230
+ );
231
+ }
232
+
233
+ /**
234
+ * The button that opens one of the bar's menus.
235
+ *
236
+ * `role="menuitem"` rather than a plain button, because it *is* an item of the
237
+ * menubar — a reader is told "File, menu item, has popup, 1 of 3" — and it is
238
+ * what makes the bar's arrow keys agree with what they were told is in it.
239
+ */
240
+ export component MenubarTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
241
+ const bar = useMenubar("Menubar.Trigger");
242
+ const menu = useMenu("Menubar.Trigger");
243
+ const value = useContext(MenubarMenuContext);
244
+ useTriggerRegistration(menu);
245
+ if (value == null) {
246
+ throw new Error("Menubar.Trigger must be rendered inside a Menubar.Menu");
247
+ }
248
+ const id = `${menu.base}-trigger`;
249
+ // The bar's single tab stop. Before anything has been focused or opened it
250
+ // belongs to the first trigger, which is a fact about the document rather
251
+ // than about this component — `useFirstItem` reads it in the bar.
252
+ const stop = bar.active == null ? bar.firstId === id : bar.active === value;
253
+
254
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]), {
255
+ "aria-controls": menu.open ? `${menu.base}-body` : undefined,
256
+ "aria-expanded": menu.open ? "true" : "false",
257
+ "aria-haspopup": "menu",
258
+ children,
259
+ // How the bar's keyboard turns a trigger element back into the menu it
260
+ // opens; the module header says why this is an attribute and the rest of
261
+ // the bar's arithmetic is not.
262
+ "data-uf-menubar-value": value,
263
+ id,
264
+ onClick: composeHandlers(rest.onClick, () => bar.setOpen(menu.open ? null : value)),
265
+ onFocus: composeHandlers(rest.onFocus, () => bar.setActive(value)),
266
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
267
+ const end = match (event.key) {
268
+ "ArrowDown" => "first",
269
+ "ArrowUp" => "last",
270
+ _ => null,
271
+ };
272
+ if (end == null) {
273
+ return;
274
+ }
275
+ event.preventDefault();
276
+ // This is an instruction for the menu body after the opening commit.
277
+ // uf-lint-disable-next-line react-compiler/immutability
278
+ menu.pendingFocusRef.current = end;
279
+ bar.setOpen(value);
280
+ }),
281
+ // React calls callback refs during commit; menu focus restoration reads it later.
282
+ // uf-lint-disable-next-line react-compiler/refs
283
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
284
+ // uf-lint-disable-next-line react-compiler/immutability
285
+ menu.triggerRef.current = element;
286
+ }),
287
+ role: "menuitem",
288
+ tabIndex: stop ? 0 : -1,
289
+ });
290
+
291
+ if (render != null) {
292
+ return render(props);
293
+ }
294
+ return <button {...props} type="button" />;
295
+ }
@@ -0,0 +1,263 @@
1
+ // @flow
2
+ "use client";
3
+
4
+ import * as React from "@uniflowed/react";
5
+ import { createContext, useContext, useMemo, useState } from "@uniflowed/react";
6
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
7
+ import { useControlled } from "./internal/controlled-state.js";
8
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
9
+ import { composeHandlers, withProps } from "./internal/merge-props.js";
10
+ import { useLocale } from "./i18n-provider.js";
11
+
12
+ type NumberFormatter = {
13
+ format: (value: number) => string,
14
+ formatToParts: (value: number) => Array<{ type: string, value: string }>,
15
+ resolvedOptions: () => { locale: string, style: string, ... },
16
+ };
17
+ export type NumberFormatOptions = {
18
+ style?: "decimal" | "percent" | "currency" | "unit",
19
+ currency?: string,
20
+ currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name",
21
+ currencySign?: "standard" | "accounting",
22
+ unit?: string,
23
+ unitDisplay?: "long" | "short" | "narrow",
24
+ useGrouping?: boolean,
25
+ minimumFractionDigits?: number,
26
+ maximumFractionDigits?: number,
27
+ minimumIntegerDigits?: number,
28
+ numberingSystem?: string,
29
+ };
30
+ // Flow's bundled Intl declarations predate formatToParts and unit formatting.
31
+ function numberFormatter(locale: string, options?: NumberFormatOptions): NumberFormatter {
32
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 20, ...options }) as $FlowFixMe;
33
+ }
34
+
35
+ /** Parse only the symbols the formatter emits, never arbitrary trailing text. */
36
+ export function parseNumber(text: string, formatter: NumberFormatter): number | null {
37
+ if (text.trim() === "") return null;
38
+ const options = formatter.resolvedOptions();
39
+ const digits = new Intl.NumberFormat(options.locale, { useGrouping: false });
40
+ let clean = text.trim();
41
+ for (let digit = 0; digit < 10; digit += 1)
42
+ clean = clean.split(digits.format(digit)).join(String(digit));
43
+ const parts = formatter.formatToParts(-12345.6);
44
+ const decimal =
45
+ numberFormatter(options.locale)
46
+ .formatToParts(1.1)
47
+ .find((part) => part.type === "decimal")?.value ?? ".";
48
+ const accounting = clean.startsWith("(") && clean.endsWith(")");
49
+ if (accounting) clean = clean.slice(1, -1);
50
+ for (const part of parts) {
51
+ if (["group", "currency", "unit", "percentSign", "literal"].includes(part.type))
52
+ clean = clean.split(part.value).join("");
53
+ if (part.type === "minusSign") clean = clean.split(part.value).join("-");
54
+ }
55
+ clean = clean
56
+ .split(decimal)
57
+ .join(".")
58
+ .replace(/[\s\u200e\u200f\u061c]/g, "");
59
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(clean)) return NaN;
60
+ const value = (Number(clean) * (accounting ? -1 : 1)) / (options.style === "percent" ? 100 : 1);
61
+ return Number.isFinite(value) ? value : NaN;
62
+ }
63
+
64
+ type NumberState = {
65
+ value: number | null,
66
+ text: string,
67
+ invalid: boolean,
68
+ disabled: boolean,
69
+ readOnly: boolean,
70
+ min: number | void,
71
+ max: number | void,
72
+ formatter: NumberFormatter,
73
+ edit: (text: string) => void,
74
+ commit: () => void,
75
+ stepBy: (direction: number) => void,
76
+ boundary: (value: number | void) => void,
77
+ };
78
+ const NumberContext: React.Context<NumberState | null> = createContext(null);
79
+ hook useNumber(): NumberState {
80
+ const state = useContext(NumberContext);
81
+ if (state == null) throw new Error("NumberField parts must be inside NumberField.Root");
82
+ return state;
83
+ }
84
+
85
+ export component NumberFieldRoot(
86
+ children: React.Node,
87
+ value?: number | null,
88
+ defaultValue?: number | null = null,
89
+ onValueChange?: (value: number | null) => void,
90
+ onValidationChange?: (invalid: boolean) => void,
91
+ min?: number,
92
+ max?: number,
93
+ step?: number,
94
+ formatOptions?: NumberFormatOptions,
95
+ disabled?: boolean = false,
96
+ readOnly?: boolean = false,
97
+ render?: RenderProp,
98
+ ...rest: Rest
99
+ ) {
100
+ const increment = step ?? (formatOptions?.style === "percent" ? 0.01 : 1);
101
+ if (
102
+ !Number.isFinite(increment) ||
103
+ increment <= 0 ||
104
+ (min != null && !Number.isFinite(min)) ||
105
+ (max != null && !Number.isFinite(max)) ||
106
+ (min != null && max != null && min > max)
107
+ ) {
108
+ throw new RangeError("NumberField requires finite bounds and a positive increment");
109
+ }
110
+ const upperBound =
111
+ max == null
112
+ ? undefined
113
+ : Number(
114
+ ((min ?? 0) + Math.floor((max - (min ?? 0)) / increment + 1e-9) * increment).toPrecision(
115
+ 15,
116
+ ),
117
+ );
118
+ const { locale } = useLocale();
119
+ const formatter = useMemo(() => numberFormatter(locale, formatOptions), [locale, formatOptions]);
120
+ const [current, setCurrent] = useControlled(value, defaultValue, onValueChange);
121
+ const [draft, setDraft] = useState<string | null>(null);
122
+ const text = draft ?? (current == null ? "" : formatter.format(current));
123
+ const parsed = parseNumber(text, formatter);
124
+ const invalid =
125
+ parsed != null &&
126
+ (!Number.isFinite(parsed) ||
127
+ (min != null && parsed < min) ||
128
+ (max != null && parsed > max) ||
129
+ Math.abs((parsed - (min ?? 0)) / increment - Math.round((parsed - (min ?? 0)) / increment)) >
130
+ 1e-7);
131
+ const assign = useStableCallback((next: number | null) => {
132
+ if (disabled || readOnly) return;
133
+ setDraft(null);
134
+ setCurrent(next);
135
+ onValidationChange?.(false);
136
+ });
137
+ const commit = useStableCallback(() => {
138
+ if (disabled || readOnly) return;
139
+ onValidationChange?.(invalid);
140
+ if (!invalid) assign(parsed);
141
+ });
142
+ const stepBy = useStableCallback((direction: number) => {
143
+ const start = parsed != null && Number.isFinite(parsed) ? parsed : (current ?? min ?? 0);
144
+ const base = min ?? 0;
145
+ const position = (start - base) / increment;
146
+ const index =
147
+ direction > 0
148
+ ? Math.floor(position + 1e-9) + direction
149
+ : Math.ceil(position - 1e-9) + direction;
150
+ const next = Number((base + index * increment).toPrecision(15));
151
+ const ceiling =
152
+ max == null
153
+ ? Infinity
154
+ : Number((base + Math.floor((max - base) / increment + 1e-9) * increment).toPrecision(15));
155
+ assign(Math.max(min ?? -Infinity, Math.min(ceiling, next)));
156
+ });
157
+ const state = {
158
+ value: current,
159
+ text,
160
+ invalid,
161
+ disabled,
162
+ readOnly,
163
+ min,
164
+ max: upperBound,
165
+ formatter,
166
+ edit: (next: string) => {
167
+ if (!disabled && !readOnly) setDraft(next);
168
+ },
169
+ commit,
170
+ stepBy,
171
+ boundary: (next: number | void) => {
172
+ if (next != null)
173
+ assign(
174
+ Number(
175
+ (
176
+ (min ?? 0) +
177
+ Math.floor((next - (min ?? 0)) / increment + 1e-9) * increment
178
+ ).toPrecision(15),
179
+ ),
180
+ );
181
+ },
182
+ };
183
+ const props = withProps(rest, { children, "data-invalid": invalid ? "" : undefined });
184
+ return (
185
+ <NumberContext.Provider value={state}>
186
+ {render != null ? render(props) : <div {...props} />}
187
+ </NumberContext.Provider>
188
+ );
189
+ }
190
+
191
+ /** Field.Control may render this input to supply the label, description and error ids. */
192
+ export component NumberFieldInput(render?: RenderProp, ...rest: Rest) {
193
+ const state = useNumber();
194
+ const props = withProps(rest, {
195
+ type: "text",
196
+ role: "spinbutton",
197
+ inputMode: "decimal",
198
+ value: state.text,
199
+ disabled: state.disabled,
200
+ readOnly: state.readOnly,
201
+ "aria-valuenow": state.value == null || !Number.isFinite(state.value) ? undefined : state.value,
202
+ "aria-valuetext": state.text || undefined,
203
+ "aria-valuemin": state.min,
204
+ "aria-valuemax": state.max,
205
+ "aria-invalid": state.invalid ? "true" : rest["aria-invalid"],
206
+ onChange: composeHandlers(rest.onChange, (event: $FlowFixMe) =>
207
+ state.edit(event.currentTarget.value),
208
+ ),
209
+ onBlur: composeHandlers(rest.onBlur, state.commit),
210
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
211
+ if (state.disabled || state.readOnly) return;
212
+ const key = event.key;
213
+ if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", "Enter"].includes(key)) {
214
+ event.preventDefault();
215
+ if (key === "Home") state.boundary(state.min);
216
+ else if (key === "End") state.boundary(state.max);
217
+ else if (key === "Enter") state.commit();
218
+ else
219
+ state.stepBy(
220
+ key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : key === "PageUp" ? 10 : -10,
221
+ );
222
+ }
223
+ }),
224
+ });
225
+ return render != null ? render(props) : <input {...props} />;
226
+ }
227
+
228
+ export component NumberFieldIncrement(
229
+ children?: React.Node = "+",
230
+ render?: RenderProp,
231
+ ...rest: Rest
232
+ ) {
233
+ const state = useNumber();
234
+ const props = withProps(rest, {
235
+ children,
236
+ type: "button",
237
+ "aria-label": rest["aria-label"] ?? "Increase",
238
+ disabled:
239
+ state.disabled ||
240
+ state.readOnly ||
241
+ (state.max != null && state.value != null && state.value >= state.max),
242
+ onClick: composeHandlers(rest.onClick, () => state.stepBy(1)),
243
+ });
244
+ return render != null ? render(props) : <button {...props} />;
245
+ }
246
+ export component NumberFieldDecrement(
247
+ children?: React.Node = "−",
248
+ render?: RenderProp,
249
+ ...rest: Rest
250
+ ) {
251
+ const state = useNumber();
252
+ const props = withProps(rest, {
253
+ children,
254
+ type: "button",
255
+ "aria-label": rest["aria-label"] ?? "Decrease",
256
+ disabled:
257
+ state.disabled ||
258
+ state.readOnly ||
259
+ (state.min != null && state.value != null && state.value <= state.min),
260
+ onClick: composeHandlers(rest.onClick, () => state.stepBy(-1)),
261
+ });
262
+ return render != null ? render(props) : <button {...props} />;
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.0.0-alpha.9",
3
+ "version": "0.2.0",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,38 +11,18 @@
11
11
  "directory": "packages/ui"
12
12
  },
13
13
  "exports": {
14
- ".": "./index.js",
15
- "./accordion": "./accordion.js",
16
- "./checkbox": "./checkbox.js",
17
- "./collapsible": "./collapsible.js",
18
- "./combobox": "./combobox.js",
19
- "./dialog": "./dialog.js",
20
- "./field": "./field.js",
21
- "./hover-card": "./hover-card.js",
22
- "./menu": "./menu.js",
23
- "./navigation-menu": "./navigation-menu.js",
24
- "./pagination": "./pagination.js",
25
- "./popover": "./popover.js",
26
- "./progress": "./progress.js",
27
- "./radio-group": "./radio-group.js",
28
- "./resizable": "./resizable.js",
29
- "./select": "./select.js",
30
- "./slider": "./slider.js",
31
- "./switch": "./switch.js",
32
- "./table": "./table.js",
33
- "./tabs": "./tabs.js",
34
- "./toast": "./toast.js",
35
- "./toggle": "./toggle.js",
36
- "./toggle-group": "./toggle-group.js",
37
- "./tooltip": "./tooltip.js"
14
+ ".": "./index.js"
38
15
  },
39
16
  "files": [
40
17
  "*.js",
41
- "internal"
18
+ "internal",
19
+ "!*.test.js"
42
20
  ],
43
21
  "dependencies": {
44
- "@uniflowed/hooks": "0.0.0-alpha.9",
45
- "@uniflowed/react": "0.0.0-alpha.9"
22
+ "@uniflowed/core": "0.2.0",
23
+ "@uniflowed/hooks": "0.2.0",
24
+ "@uniflowed/react": "0.2.0",
25
+ "@uniflowed/state": "0.2.0"
46
26
  },
47
27
  "peerDependencies": {
48
28
  "react": ">=19"