@lumiose/ui 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Navigation Menu (decision-62, TASK-380): site navigation whose items open panels of links, after
|
|
2
|
+
// Base UI's NavigationMenu and the WAI-ARIA disclosure navigation pattern. Root is a `<nav>`
|
|
3
|
+
// landmark (a `<div>` when nested in another menu's panel) around a list; each item's trigger
|
|
4
|
+
// opens its content through `popovertarget`: a `popover="auto"` placed under the trigger by CSS
|
|
5
|
+
// anchor positioning. So without JavaScript the links work, a panel opens on a click, one at a
|
|
6
|
+
// time, and closes on Escape or a click outside. `<lu-navigation-menu>`
|
|
7
|
+
// (./navigation-menu.script.ts) adds hover intent, the arrow keys, closing when focus leaves,
|
|
8
|
+
// `value` and the activation direction. Base UI's Viewport, Positioner, Portal, Backdrop and Arrow
|
|
9
|
+
// are left out: the top layer and anchor positioning place each panel, and without a portal the
|
|
10
|
+
// panels cannot share one viewport.
|
|
11
|
+
import { createContext, Dynamic, effect, onMount, ref, untrack, useContext, useId } from "lumiose";
|
|
12
|
+
import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
|
|
13
|
+
import navigationMenuScript from "./navigation-menu.script.ts?client";
|
|
14
|
+
import type {
|
|
15
|
+
NavigationMenuElement,
|
|
16
|
+
NavigationMenuValueChangeDetail,
|
|
17
|
+
} from "./navigation-menu.script.ts";
|
|
18
|
+
|
|
19
|
+
declare module "lumiose/jsx-runtime" {
|
|
20
|
+
namespace JSX {
|
|
21
|
+
interface IntrinsicElements {
|
|
22
|
+
/**
|
|
23
|
+
* NavigationMenu.Root's element: it opens and closes its items' contents. Property `value`;
|
|
24
|
+
* event `valuechange` (`detail.value`).
|
|
25
|
+
*/
|
|
26
|
+
"lu-navigation-menu": JSX.HTMLProps<NavigationMenuElement> & {
|
|
27
|
+
"data-delay"?: number;
|
|
28
|
+
"data-close-delay"?: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Which way a navigation menu's items run. */
|
|
35
|
+
export type NavigationMenuOrientation = "horizontal" | "vertical";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `NavigationMenu.Root` renders a `<nav>` (a `<div>` inside another navigation menu's content)
|
|
39
|
+
* inside `<lu-navigation-menu>`. `bind:value={signal}` stands in for `value` and `onValueChange`.
|
|
40
|
+
*/
|
|
41
|
+
export type NavigationMenuRootProps = Omit<JSX.IntrinsicElements["nav"], symbol> & {
|
|
42
|
+
/**
|
|
43
|
+
* The `value` of the item whose content is open, or `null` for none; opens or closes them when it
|
|
44
|
+
* changes, in the browser. Pair it with `onValueChange`; leave both out and the menu keeps its own
|
|
45
|
+
* state. Server HTML always starts closed.
|
|
46
|
+
*/
|
|
47
|
+
value?: string | null;
|
|
48
|
+
/** The item open at first, in the browser, when `value` is left out. */
|
|
49
|
+
defaultValue?: string | null;
|
|
50
|
+
/** Called after an item's content opens (its `value`) or the open one closes (`null`). */
|
|
51
|
+
onValueChange?: (value: string | null) => void;
|
|
52
|
+
/** How long the pointer rests on a trigger before its content opens, in milliseconds. Default `50`. */
|
|
53
|
+
delay?: number;
|
|
54
|
+
/** How long after the pointer leaves an item its content closes, in milliseconds. Default `50`. */
|
|
55
|
+
closeDelay?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Which way the items run: `"vertical"` moves between them with Arrow Up and Down, and places
|
|
58
|
+
* contents at the inline end. Default `"horizontal"`.
|
|
59
|
+
*/
|
|
60
|
+
orientation?: NavigationMenuOrientation;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A `<ul>` of the menu's items. */
|
|
64
|
+
export type NavigationMenuListProps = JSX.IntrinsicElements["ul"];
|
|
65
|
+
|
|
66
|
+
/** An `<li>`: a trigger with its content, or a link. */
|
|
67
|
+
export type NavigationMenuItemProps = JSX.IntrinsicElements["li"] & {
|
|
68
|
+
/** The value `Root`'s `value` names this item by. Default: a generated id. */
|
|
69
|
+
value?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The `<button popovertarget="…">` that opens and closes its item's content. */
|
|
73
|
+
export type NavigationMenuTriggerProps = JSX.IntrinsicElements["button"];
|
|
74
|
+
|
|
75
|
+
/** A `<span aria-hidden="true">` inside the trigger, such as a chevron. */
|
|
76
|
+
export type NavigationMenuIconProps = JSX.IntrinsicElements["span"];
|
|
77
|
+
|
|
78
|
+
/** The `<div popover="auto">` of an item's links, placed next to its trigger. */
|
|
79
|
+
export type NavigationMenuContentProps = JSX.IntrinsicElements["div"] & PlacementProps;
|
|
80
|
+
|
|
81
|
+
/** An `<a>`: a link in the list or in a content. */
|
|
82
|
+
export type NavigationMenuLinkProps = JSX.IntrinsicElements["a"] & {
|
|
83
|
+
/** The link to the current page: `aria-current="page"` and `data-active`. */
|
|
84
|
+
active?: boolean;
|
|
85
|
+
/** Whether following the link closes the open content. Default `false`. */
|
|
86
|
+
closeOnClick?: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
interface RootContextValue {
|
|
90
|
+
orientation: NavigationMenuOrientation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ItemContextValue {
|
|
94
|
+
id: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const RootContext = createContext<RootContextValue>();
|
|
98
|
+
const ItemContext = createContext<ItemContextValue>();
|
|
99
|
+
|
|
100
|
+
function useItem(part: string): ItemContextValue {
|
|
101
|
+
const item = useContext(ItemContext);
|
|
102
|
+
if (!item) throw new Error(`NavigationMenu.${part} must be inside <NavigationMenu.Item>.`);
|
|
103
|
+
return item;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Root({
|
|
107
|
+
value,
|
|
108
|
+
defaultValue,
|
|
109
|
+
onValueChange,
|
|
110
|
+
delay,
|
|
111
|
+
closeDelay,
|
|
112
|
+
orientation,
|
|
113
|
+
children,
|
|
114
|
+
...rest
|
|
115
|
+
}: NavigationMenuRootProps) {
|
|
116
|
+
// Hover intent, keys and `value` where JavaScript runs; the page loads it once (decision-63).
|
|
117
|
+
navigationMenuScript();
|
|
118
|
+
const nested = useContext(RootContext) !== undefined;
|
|
119
|
+
const host = ref<NavigationMenuElement>();
|
|
120
|
+
const vertical = () => orientation === "vertical";
|
|
121
|
+
|
|
122
|
+
// `value` drives the element; its `valuechange` event reports back, so a signal written by
|
|
123
|
+
// `onValueChange` sees the same value again and nothing loops. A content opened from the server
|
|
124
|
+
// HTML before the island hydrated stays open, and the signal hears of it.
|
|
125
|
+
let mounted = false;
|
|
126
|
+
effect(() => {
|
|
127
|
+
const element = host();
|
|
128
|
+
const wanted = value;
|
|
129
|
+
if (!element?.isConnected || wanted === undefined) return;
|
|
130
|
+
if (!mounted && wanted === null && element.value !== null) {
|
|
131
|
+
untrack(() => onValueChange?.(element.value));
|
|
132
|
+
} else {
|
|
133
|
+
element.value = wanted;
|
|
134
|
+
}
|
|
135
|
+
mounted = true;
|
|
136
|
+
});
|
|
137
|
+
onMount(() => {
|
|
138
|
+
const element = host();
|
|
139
|
+
if (element && value === undefined && defaultValue != null) element.value = defaultValue;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const attributes = () => ({
|
|
143
|
+
...rest,
|
|
144
|
+
"data-orientation": vertical() ? "vertical" : "horizontal",
|
|
145
|
+
});
|
|
146
|
+
return (
|
|
147
|
+
<RootContext value={{ orientation: vertical() ? "vertical" : "horizontal" }}>
|
|
148
|
+
<lu-navigation-menu
|
|
149
|
+
ref={host}
|
|
150
|
+
data-delay={delay}
|
|
151
|
+
data-close-delay={closeDelay}
|
|
152
|
+
data-orientation={vertical() ? "vertical" : "horizontal"}
|
|
153
|
+
on:valuechange={(event: CustomEvent<NavigationMenuValueChangeDetail>) =>
|
|
154
|
+
onValueChange?.(event.detail.value)
|
|
155
|
+
}
|
|
156
|
+
>
|
|
157
|
+
{/* A nested menu is a `<div>`, inside its parent's landmark. */}
|
|
158
|
+
<Dynamic component={nested ? "div" : "nav"} {...attributes()}>
|
|
159
|
+
{children}
|
|
160
|
+
</Dynamic>
|
|
161
|
+
</lu-navigation-menu>
|
|
162
|
+
</RootContext>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Children are rendered explicitly: a spread does not pass them on (TASK-347).
|
|
167
|
+
function List({ children, ...rest }: NavigationMenuListProps) {
|
|
168
|
+
return <ul {...rest}>{children}</ul>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Item({ value, children, ...rest }: NavigationMenuItemProps) {
|
|
172
|
+
const id = useId();
|
|
173
|
+
return (
|
|
174
|
+
<ItemContext value={{ id }}>
|
|
175
|
+
<li {...rest} data-value={value ?? id}>
|
|
176
|
+
{children}
|
|
177
|
+
</li>
|
|
178
|
+
</ItemContext>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function Trigger({ children, style, ...rest }: NavigationMenuTriggerProps) {
|
|
183
|
+
const item = useItem("Trigger");
|
|
184
|
+
return (
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
{...rest}
|
|
188
|
+
// Nothing points at the trigger: a caller's `id` names it (TASK-441 M39).
|
|
189
|
+
id={rest.id ?? `${item.id}-trigger`}
|
|
190
|
+
popovertarget={`${item.id}-content`}
|
|
191
|
+
style={withStyle(style, { "anchor-name": anchorName(item.id) })}
|
|
192
|
+
>
|
|
193
|
+
{children}
|
|
194
|
+
</button>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Icon({ children, ...rest }: NavigationMenuIconProps) {
|
|
199
|
+
return (
|
|
200
|
+
<span aria-hidden="true" {...rest}>
|
|
201
|
+
{children}
|
|
202
|
+
</span>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Content({
|
|
207
|
+
children,
|
|
208
|
+
side,
|
|
209
|
+
align,
|
|
210
|
+
sideOffset,
|
|
211
|
+
alignOffset,
|
|
212
|
+
style,
|
|
213
|
+
...rest
|
|
214
|
+
}: NavigationMenuContentProps) {
|
|
215
|
+
const item = useItem("Content");
|
|
216
|
+
const root = useContext(RootContext);
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
{...rest}
|
|
220
|
+
id={`${item.id}-content`}
|
|
221
|
+
popover="auto"
|
|
222
|
+
{...placement(
|
|
223
|
+
{ side, align, sideOffset, alignOffset, style },
|
|
224
|
+
item.id,
|
|
225
|
+
root?.orientation === "vertical" ? "inline-end" : "bottom",
|
|
226
|
+
root?.orientation === "vertical" ? "start" : "center",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{children}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function Link({ children, active, closeOnClick, ...rest }: NavigationMenuLinkProps) {
|
|
235
|
+
return (
|
|
236
|
+
<a
|
|
237
|
+
{...rest}
|
|
238
|
+
aria-current={active ? "page" : rest["aria-current"]}
|
|
239
|
+
data-active={active || undefined}
|
|
240
|
+
data-close-on-click={closeOnClick ? "true" : undefined}
|
|
241
|
+
>
|
|
242
|
+
{children}
|
|
243
|
+
</a>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Site navigation whose items open panels of links, after Base UI's NavigationMenu: Root, List,
|
|
249
|
+
* Item, Trigger, Icon, Content and Link. A `<nav>` landmark whose links work without JavaScript.
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* <NavigationMenu.Root aria-label="Main">
|
|
253
|
+
* <NavigationMenu.List>
|
|
254
|
+
* <NavigationMenu.Item>
|
|
255
|
+
* <NavigationMenu.Trigger>Products</NavigationMenu.Trigger>
|
|
256
|
+
* <NavigationMenu.Content>
|
|
257
|
+
* <NavigationMenu.Link href="/analytics">Analytics</NavigationMenu.Link>
|
|
258
|
+
* </NavigationMenu.Content>
|
|
259
|
+
* </NavigationMenu.Item>
|
|
260
|
+
* <NavigationMenu.Item>
|
|
261
|
+
* <NavigationMenu.Link href="/pricing">Pricing</NavigationMenu.Link>
|
|
262
|
+
* </NavigationMenu.Item>
|
|
263
|
+
* </NavigationMenu.List>
|
|
264
|
+
* </NavigationMenu.Root>
|
|
265
|
+
*/
|
|
266
|
+
export const NavigationMenu = { Root, List, Item, Trigger, Icon, Content, Link };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A number field's values as text and back, shared by the component (server HTML, islands) and its
|
|
2
|
+
// script: `Intl.NumberFormat` writes them, and `parseNumber` reads what a person typed in the same
|
|
3
|
+
// locale and format (grouping, the decimal sign, the locale's digits, a currency, a percent sign).
|
|
4
|
+
import { digitValue } from "../locale/digits.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The formatter for `format` (`Intl.NumberFormat` options) and `locale`. Options the browser
|
|
8
|
+
* refuses (`{"style":"currency"}` without a currency) are left out, and then an invalid locale,
|
|
9
|
+
* so the field still works (TASK-473, L12); the server falls back the same way.
|
|
10
|
+
*/
|
|
11
|
+
export function formatter(
|
|
12
|
+
format: Intl.NumberFormatOptions | undefined,
|
|
13
|
+
locale: Intl.LocalesArgument | null | undefined,
|
|
14
|
+
): Intl.NumberFormat {
|
|
15
|
+
try {
|
|
16
|
+
return new Intl.NumberFormat(locale ?? undefined, format);
|
|
17
|
+
} catch {
|
|
18
|
+
try {
|
|
19
|
+
return new Intl.NumberFormat(locale ?? undefined);
|
|
20
|
+
} catch {
|
|
21
|
+
return new Intl.NumberFormat();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `value` as the field shows it: `""` for no value. */
|
|
27
|
+
export function formatNumber(value: number | null, numbers: Intl.NumberFormat): string {
|
|
28
|
+
return value === null || !Number.isFinite(value) ? "" : numbers.format(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The signs `numbers` writes: its decimal sign and its digits, 0 to 9. */
|
|
32
|
+
function signs(numbers: Intl.NumberFormat) {
|
|
33
|
+
const { locale, numberingSystem } = numbers.resolvedOptions();
|
|
34
|
+
const plain = new Intl.NumberFormat(locale, { numberingSystem, useGrouping: false });
|
|
35
|
+
const decimal =
|
|
36
|
+
numbers.formatToParts(1.5).find((part) => part.type === "decimal")?.value ??
|
|
37
|
+
plain.formatToParts(1.5).find((part) => part.type === "decimal")?.value ??
|
|
38
|
+
".";
|
|
39
|
+
const digits = new Map<string, string>();
|
|
40
|
+
for (let digit = 0; digit <= 9; digit++) digits.set(plain.format(digit), String(digit));
|
|
41
|
+
return { decimal, digits };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The number in `text`, as typed in `numbers`' locale and format: `null` when it is empty, `NaN`
|
|
46
|
+
* when it holds no number. Grouping, currency and unit signs and spaces are skipped; the locale's
|
|
47
|
+
* decimal sign (or a `.`) marks the fraction; a minus sign or parentheses make it negative; with
|
|
48
|
+
* the percent style, `50 %` is `0.5`.
|
|
49
|
+
*/
|
|
50
|
+
export function parseNumber(text: string, numbers: Intl.NumberFormat): number | null {
|
|
51
|
+
const trimmed = text.trim();
|
|
52
|
+
if (trimmed === "") return null;
|
|
53
|
+
const { decimal, digits } = signs(numbers);
|
|
54
|
+
let normalized = "";
|
|
55
|
+
let negative = false;
|
|
56
|
+
let fraction = false;
|
|
57
|
+
for (const char of trimmed) {
|
|
58
|
+
if (/\d/.test(char)) normalized += char;
|
|
59
|
+
else if (digits.has(char)) normalized += digits.get(char);
|
|
60
|
+
// Digits of another script (full-width `1`, `٤`): what a keyboard or an IME may type (M34).
|
|
61
|
+
else if (digitValue(char) !== undefined) normalized += String(digitValue(char));
|
|
62
|
+
else if ((char === decimal || (char === "." && decimal !== ",")) && !fraction) {
|
|
63
|
+
normalized += ".";
|
|
64
|
+
fraction = true;
|
|
65
|
+
} else if (char === "-" || char === "−" || char === "(") negative = true;
|
|
66
|
+
}
|
|
67
|
+
if (!/\d/.test(normalized)) return Number.NaN;
|
|
68
|
+
const value = Number(normalized) * (negative ? -1 : 1);
|
|
69
|
+
return numbers.resolvedOptions().style === "percent" ? value / 100 : value;
|
|
70
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The CSS Number Field needs to work, and nothing else (decision-62). Its own buttons replace the
|
|
3
|
+
* native number input's spinners once its script runs (`:defined`); until then (or without
|
|
4
|
+
* JavaScript) the buttons, which need the script, stay hidden and the native spinners work. A
|
|
5
|
+
* scrub area takes the pointer for its drag. It sits in the `lu.core` layer, so any app CSS wins.
|
|
6
|
+
*/
|
|
7
|
+
@layer lu.core, lu.theme;
|
|
8
|
+
|
|
9
|
+
@layer lu.core {
|
|
10
|
+
lu-number-field {
|
|
11
|
+
display: block;
|
|
12
|
+
}
|
|
13
|
+
lu-number-field:not(:defined) button[data-action] {
|
|
14
|
+
visibility: hidden;
|
|
15
|
+
}
|
|
16
|
+
lu-number-field:defined input[type="number"] {
|
|
17
|
+
appearance: textfield;
|
|
18
|
+
}
|
|
19
|
+
lu-number-field:defined input[type="number"]::-webkit-inner-spin-button,
|
|
20
|
+
lu-number-field:defined input[type="number"]::-webkit-outer-spin-button {
|
|
21
|
+
appearance: none;
|
|
22
|
+
margin: 0;
|
|
23
|
+
}
|
|
24
|
+
/* Held, a button must not select text or open a touch callout. */
|
|
25
|
+
lu-number-field button[data-action] {
|
|
26
|
+
user-select: none;
|
|
27
|
+
-webkit-touch-callout: none;
|
|
28
|
+
touch-action: manipulation;
|
|
29
|
+
}
|
|
30
|
+
lu-number-field [data-scrub] {
|
|
31
|
+
cursor: ew-resize;
|
|
32
|
+
touch-action: none;
|
|
33
|
+
user-select: none;
|
|
34
|
+
}
|
|
35
|
+
lu-number-field [data-scrub][data-direction="vertical"] {
|
|
36
|
+
cursor: ns-resize;
|
|
37
|
+
}
|
|
38
|
+
}
|