@tempoxyz/ds 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/mobile/components/Avatar.d.ts +1 -1
- package/dist/mobile/components/Avatar.d.ts.map +1 -1
- package/dist/mobile/components/Avatar.js.map +1 -1
- package/dist/mobile/components/Button.d.ts +1 -1
- package/dist/mobile/components/Button.d.ts.map +1 -1
- package/dist/mobile/components/Button.js +5 -1
- package/dist/mobile/components/Button.js.map +1 -1
- package/dist/mobile/components/CodeInput.d.ts +1 -1
- package/dist/mobile/components/CodeInput.d.ts.map +1 -1
- package/dist/mobile/components/CodeInput.js.map +1 -1
- package/dist/mobile/components/IconButton.d.ts +1 -1
- package/dist/mobile/components/IconButton.d.ts.map +1 -1
- package/dist/mobile/components/IconButton.js +5 -1
- package/dist/mobile/components/IconButton.js.map +1 -1
- package/dist/mobile/components/Skeleton.d.ts.map +1 -1
- package/dist/mobile/components/Skeleton.js.map +1 -1
- package/dist/mobile/components/Spinner.d.ts +1 -1
- package/dist/mobile/components/Spinner.d.ts.map +1 -1
- package/dist/mobile/components/Spinner.js.map +1 -1
- package/dist/mobile/components/TextButton.d.ts.map +1 -1
- package/dist/mobile/components/TextButton.js +7 -2
- package/dist/mobile/components/TextButton.js.map +1 -1
- package/dist/mobile/components/TextInput.d.ts.map +1 -1
- package/dist/mobile/components/TextInput.js +1 -1
- package/dist/mobile/components/TextInput.js.map +1 -1
- package/dist/oxlint/index.d.ts +43 -0
- package/dist/oxlint/index.d.ts.map +1 -0
- package/dist/oxlint/index.js +68 -0
- package/dist/oxlint/index.js.map +1 -0
- package/dist/oxlint/internal/Imports.d.ts +22 -0
- package/dist/oxlint/internal/Imports.d.ts.map +1 -0
- package/dist/oxlint/internal/Imports.js +52 -0
- package/dist/oxlint/internal/Imports.js.map +1 -0
- package/dist/oxlint/rules/NoCrossSurface.d.ts +16 -0
- package/dist/oxlint/rules/NoCrossSurface.d.ts.map +1 -0
- package/dist/oxlint/rules/NoCrossSurface.js +55 -0
- package/dist/oxlint/rules/NoCrossSurface.js.map +1 -0
- package/dist/oxlint/rules/NoInternalImports.d.ts +8 -0
- package/dist/oxlint/rules/NoInternalImports.d.ts.map +1 -0
- package/dist/oxlint/rules/NoInternalImports.js +22 -0
- package/dist/oxlint/rules/NoInternalImports.js.map +1 -0
- package/dist/oxlint/rules/NoRawValues.d.ts +9 -0
- package/dist/oxlint/rules/NoRawValues.d.ts.map +1 -0
- package/dist/oxlint/rules/NoRawValues.js +73 -0
- package/dist/oxlint/rules/NoRawValues.js.map +1 -0
- package/dist/oxlint/rules/PreferComponent.d.ts +16 -0
- package/dist/oxlint/rules/PreferComponent.d.ts.map +1 -0
- package/dist/oxlint/rules/PreferComponent.js +92 -0
- package/dist/oxlint/rules/PreferComponent.js.map +1 -0
- package/dist/oxlint/rules/RequireAccessibleName.d.ts +9 -0
- package/dist/oxlint/rules/RequireAccessibleName.d.ts.map +1 -0
- package/dist/oxlint/rules/RequireAccessibleName.js +104 -0
- package/dist/oxlint/rules/RequireAccessibleName.js.map +1 -0
- package/dist/web/components/Amount.d.ts +10 -3
- package/dist/web/components/Amount.d.ts.map +1 -1
- package/dist/web/components/Amount.js +19 -9
- package/dist/web/components/Amount.js.map +1 -1
- package/dist/web/components/AmountInput.d.ts +15 -2
- package/dist/web/components/AmountInput.d.ts.map +1 -1
- package/dist/web/components/AmountInput.js +42 -11
- package/dist/web/components/AmountInput.js.map +1 -1
- package/dist/web/components/AmountInput.js.zyzz.json +1 -1
- package/dist/web/components/Autocomplete.js.zyzz.json +1 -1
- package/dist/web/components/Banner.js +1 -1
- package/dist/web/components/Calendar.d.ts.map +1 -1
- package/dist/web/components/Calendar.js +7 -5
- package/dist/web/components/Calendar.js.map +1 -1
- package/dist/web/components/Callout.d.ts.map +1 -1
- package/dist/web/components/Callout.js +4 -1
- package/dist/web/components/Callout.js.map +1 -1
- package/dist/web/components/Combobox.js.zyzz.json +1 -1
- package/dist/web/components/DatePicker.css +1 -1
- package/dist/web/components/DatePicker.d.ts +1 -1
- package/dist/web/components/DatePicker.js.zyzz.json +1 -1
- package/dist/web/components/Dialog.js +2 -2
- package/dist/web/components/Dialog.js.map +1 -1
- package/dist/web/components/Drawer.js +2 -2
- package/dist/web/components/Drawer.js.map +1 -1
- package/dist/web/components/Field.d.ts +7 -0
- package/dist/web/components/Field.d.ts.map +1 -1
- package/dist/web/components/Field.js +25 -3
- package/dist/web/components/Field.js.map +1 -1
- package/dist/web/components/Input.css +1 -1
- package/dist/web/components/Input.js.zyzz.json +1 -1
- package/dist/web/components/Kbd.d.ts +31 -9
- package/dist/web/components/Kbd.d.ts.map +1 -1
- package/dist/web/components/Kbd.js +55 -13
- package/dist/web/components/Kbd.js.map +1 -1
- package/dist/web/components/Menu.css +1 -1
- package/dist/web/components/Menu.js +1 -1
- package/dist/web/components/Menu.js.zyzz.json +1 -1
- package/dist/web/components/NumberField.css +1 -1
- package/dist/web/components/NumberField.js.zyzz.json +1 -1
- package/dist/web/components/Pagination.css +1 -1
- package/dist/web/components/Pagination.d.ts +1 -1
- package/dist/web/components/Pagination.js.zyzz.json +1 -1
- package/dist/web/components/Rows.css +1 -1
- package/dist/web/components/Rows.d.ts +1 -1
- package/dist/web/components/Rows.d.ts.map +1 -1
- package/dist/web/components/Rows.js +3 -3
- package/dist/web/components/Rows.js.map +1 -1
- package/dist/web/components/Rows.js.zyzz.json +1 -1
- package/dist/web/components/Select.css +1 -1
- package/dist/web/components/Select.js.zyzz.json +1 -1
- package/dist/web/components/Snippet.css +1 -1
- package/dist/web/components/Snippet.js +2 -2
- package/dist/web/components/Snippet.js.zyzz.json +1 -1
- package/dist/web/components/Table.css +1 -1
- package/dist/web/components/Table.d.ts +6 -3
- package/dist/web/components/Table.d.ts.map +1 -1
- package/dist/web/components/Table.js +5 -2
- package/dist/web/components/Table.js.map +1 -1
- package/dist/web/components/Table.js.zyzz.json +1 -1
- package/dist/web/components/Textarea.css +1 -1
- package/dist/web/components/Textarea.d.ts +1 -1
- package/dist/web/components/Textarea.js.zyzz.json +1 -1
- package/dist/web/components/Toast.d.ts.map +1 -1
- package/dist/web/components/Toast.js +5 -1
- package/dist/web/components/Toast.js.map +1 -1
- package/dist/web/components/internal/Field.css +1 -1
- package/dist/web/components/internal/Field.js.zyzz.json +1 -1
- package/dist/web/components/internal/InputGroup.d.ts.map +1 -1
- package/dist/web/components/internal/InputGroup.js.map +1 -1
- package/dist/web/components/internal/Listbox.css +1 -1
- package/dist/web/components/internal/Listbox.js.zyzz.json +1 -1
- package/dist/web/icons/BadgeX.d.ts.map +1 -1
- package/dist/web/icons/BadgeX.js.map +1 -1
- package/dist/web/icons/InfinityIcon.d.ts.map +1 -1
- package/dist/web/icons/InfinityIcon.js.map +1 -1
- package/dist/web/icons/LightMode.d.ts.map +1 -1
- package/dist/web/icons/LightMode.js.map +1 -1
- package/dist/web/icons/OctagonAlert.d.ts.map +1 -1
- package/dist/web/icons/OctagonAlert.js.map +1 -1
- package/dist/web/icons/Unlink.d.ts.map +1 -1
- package/dist/web/icons/Unlink.js.map +1 -1
- package/dist/web/index.js.zyzz.json +1 -1
- package/package.json +7 -1
- package/src/mobile/components/Avatar.tsx +2 -2
- package/src/mobile/components/Button.tsx +5 -2
- package/src/mobile/components/CodeInput.tsx +2 -2
- package/src/mobile/components/IconButton.tsx +5 -2
- package/src/mobile/components/Skeleton.tsx +1 -1
- package/src/mobile/components/Spinner.tsx +1 -1
- package/src/mobile/components/TextButton.tsx +6 -2
- package/src/mobile/components/TextInput.tsx +1 -1
- package/src/oxlint/index.ts +89 -0
- package/src/oxlint/internal/Imports.ts +68 -0
- package/src/oxlint/rules/NoCrossSurface.ts +71 -0
- package/src/oxlint/rules/NoInternalImports.ts +25 -0
- package/src/oxlint/rules/NoRawValues.ts +76 -0
- package/src/oxlint/rules/PreferComponent.ts +113 -0
- package/src/oxlint/rules/RequireAccessibleName.ts +132 -0
- package/src/web/components/Amount.tsx +29 -9
- package/src/web/components/AmountInput.tsx +48 -12
- package/src/web/components/Banner.tsx +1 -1
- package/src/web/components/Calendar.tsx +5 -6
- package/src/web/components/Callout.tsx +4 -1
- package/src/web/components/DatePicker.tsx +1 -1
- package/src/web/components/Dialog.tsx +2 -2
- package/src/web/components/Drawer.tsx +2 -2
- package/src/web/components/Field.tsx +37 -2
- package/src/web/components/Input.tsx +1 -1
- package/src/web/components/Kbd.tsx +76 -20
- package/src/web/components/Menu.tsx +2 -2
- package/src/web/components/NumberField.tsx +1 -1
- package/src/web/components/Pagination.tsx +1 -1
- package/src/web/components/Rows.tsx +1 -2
- package/src/web/components/Select.tsx +1 -1
- package/src/web/components/Snippet.tsx +1 -1
- package/src/web/components/Table.tsx +7 -3
- package/src/web/components/Textarea.tsx +1 -1
- package/src/web/components/Toast.tsx +5 -1
- package/src/web/components/internal/Field.ts +1 -1
- package/src/web/components/internal/InputGroup.ts +1 -1
- package/src/web/components/internal/Listbox.ts +2 -2
- package/src/web/icons/BadgeX.tsx +1 -1
- package/src/web/icons/InfinityIcon.tsx +1 -1
- package/src/web/icons/LightMode.tsx +1 -1
- package/src/web/icons/OctagonAlert.tsx +1 -1
- package/src/web/icons/Unlink.tsx +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Requires a name on `@tempoxyz/ds` components that render no text of their own. @module */
|
|
2
|
+
import type { ESTree, Rule } from '@oxlint/plugins'
|
|
3
|
+
import * as Imports from '../internal/Imports.ts'
|
|
4
|
+
|
|
5
|
+
type Target = {
|
|
6
|
+
/** Attributes that name the element. */
|
|
7
|
+
readonly names: readonly string[]
|
|
8
|
+
/** An attribute and value that make the component icon-only. When absent, every use needs a name. */
|
|
9
|
+
readonly when?: { readonly attribute: string; readonly value: string } | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const list = new Intl.ListFormat('en', { type: 'disjunction' })
|
|
13
|
+
const web = ['aria-label', 'aria-labelledby']
|
|
14
|
+
const mobile = ['accessibilityLabel', 'accessibilityLabelledBy', 'aria-label', 'aria-labelledby']
|
|
15
|
+
|
|
16
|
+
// Components from each surface whose rendered output has no text to name it.
|
|
17
|
+
const targets: Record<Imports.Surface, Record<string, Target>> = {
|
|
18
|
+
mobile: { IconButton: { names: mobile } },
|
|
19
|
+
web: {
|
|
20
|
+
Button: { names: web, when: { attribute: 'shape', value: 'icon' } },
|
|
21
|
+
'Button.Link': { names: web, when: { attribute: 'shape', value: 'icon' } },
|
|
22
|
+
Gauge: { names: web },
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reports an icon-only `Button` or `Button.Link`, a `Gauge`, or a Mobile `IconButton` without an
|
|
28
|
+
* accessible name. An element passed to a `render` prop can take its name from the element that
|
|
29
|
+
* owns the prop. Elements with a spread attribute are skipped, since the spread can supply the name.
|
|
30
|
+
*/
|
|
31
|
+
export const rule: Rule = {
|
|
32
|
+
meta: {
|
|
33
|
+
docs: { description: 'Name `@tempoxyz/ds` components that render no text.' },
|
|
34
|
+
messages: {
|
|
35
|
+
missing: "'{{component}}' renders no text. Name it with {{names}}.",
|
|
36
|
+
},
|
|
37
|
+
schema: [],
|
|
38
|
+
type: 'problem',
|
|
39
|
+
},
|
|
40
|
+
create(context) {
|
|
41
|
+
type Binding = { imported: string | undefined; surface: Imports.Surface }
|
|
42
|
+
|
|
43
|
+
const bindings = new Map<string, Binding>()
|
|
44
|
+
|
|
45
|
+
function attribute(node: ESTree.JSXOpeningElement, name: string) {
|
|
46
|
+
return node.attributes.find(
|
|
47
|
+
(attribute): attribute is ESTree.JSXAttribute =>
|
|
48
|
+
attribute.type === 'JSXAttribute' &&
|
|
49
|
+
attribute.name.type === 'JSXIdentifier' &&
|
|
50
|
+
attribute.name.name === name,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function literal(node: ESTree.JSXAttribute) {
|
|
55
|
+
const value =
|
|
56
|
+
node.value?.type === 'JSXExpressionContainer' ? node.value.expression : node.value
|
|
57
|
+
|
|
58
|
+
return value?.type === 'Literal' ? value.value : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function named(target: Target, node: ESTree.JSXOpeningElement) {
|
|
62
|
+
if (node.attributes.some((attribute) => attribute.type === 'JSXSpreadAttribute')) return true
|
|
63
|
+
|
|
64
|
+
return target.names.some((name) => {
|
|
65
|
+
const found = attribute(node, name)
|
|
66
|
+
|
|
67
|
+
return found !== undefined && literal(found) !== ''
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function path(node: ESTree.JSXElementName | ESTree.JSXMemberExpressionObject): string[] {
|
|
72
|
+
if (node.type === 'JSXIdentifier') return [node.name]
|
|
73
|
+
if (node.type === 'JSXMemberExpression') return [...path(node.object), node.property.name]
|
|
74
|
+
return []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
ImportDeclaration(node) {
|
|
79
|
+
const surface = Imports.surface(node.source.value)
|
|
80
|
+
|
|
81
|
+
if (!surface || node.importKind === 'type') return
|
|
82
|
+
for (const specifier of node.specifiers) {
|
|
83
|
+
if (specifier.type === 'ImportNamespaceSpecifier')
|
|
84
|
+
bindings.set(specifier.local.name, { imported: undefined, surface })
|
|
85
|
+
if (specifier.type === 'ImportSpecifier' && specifier.importKind !== 'type')
|
|
86
|
+
bindings.set(specifier.local.name, {
|
|
87
|
+
imported:
|
|
88
|
+
specifier.imported.type === 'Identifier'
|
|
89
|
+
? specifier.imported.name
|
|
90
|
+
: String(specifier.imported.value),
|
|
91
|
+
surface,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
JSXOpeningElement(node) {
|
|
96
|
+
const [head, ...rest] = path(node.name)
|
|
97
|
+
const binding = head === undefined ? undefined : bindings.get(head)
|
|
98
|
+
|
|
99
|
+
if (!binding) return
|
|
100
|
+
|
|
101
|
+
const component = (binding.imported ? [binding.imported, ...rest] : rest).join('.')
|
|
102
|
+
const target = targets[binding.surface][component]
|
|
103
|
+
|
|
104
|
+
if (!target) return
|
|
105
|
+
if (target.when) {
|
|
106
|
+
const condition = attribute(node, target.when.attribute)
|
|
107
|
+
|
|
108
|
+
if (!condition || literal(condition) !== target.when.value) return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// An element passed to `render` takes its attributes from the element that owns the prop.
|
|
112
|
+
const owner = (() => {
|
|
113
|
+
for (let parent: ESTree.Node | null = node.parent; parent; parent = parent.parent)
|
|
114
|
+
if (
|
|
115
|
+
parent.type === 'JSXAttribute' &&
|
|
116
|
+
parent.name.type === 'JSXIdentifier' &&
|
|
117
|
+
parent.name.name === 'render'
|
|
118
|
+
)
|
|
119
|
+
return parent.parent.type === 'JSXOpeningElement' ? parent.parent : undefined
|
|
120
|
+
return undefined
|
|
121
|
+
})()
|
|
122
|
+
|
|
123
|
+
if (!named(target, node) && (!owner || !named(target, owner)))
|
|
124
|
+
context.report({
|
|
125
|
+
data: { component, names: list.format(target.names.map((name) => `\`${name}\``)) },
|
|
126
|
+
messageId: 'missing',
|
|
127
|
+
node,
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
@@ -3,9 +3,9 @@ import type * as zyzz from 'zyzz'
|
|
|
3
3
|
import { style, variants } from '../zyzz.config.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Formats a number in
|
|
7
|
-
* an ISO 4217 code as money, and `symbol` follows the number with a token
|
|
8
|
-
* mutually exclusive.
|
|
6
|
+
* Formats a number in `en-US`, or in `locale` when set, inside a `span` with tabular figures.
|
|
7
|
+
* `currency` formats an ISO 4217 code as money, and `symbol` follows the number with a token
|
|
8
|
+
* symbol. The two are mutually exclusive.
|
|
9
9
|
*
|
|
10
10
|
* - Pass amounts as decimal strings to avoid floating-point rounding.
|
|
11
11
|
* - Always pass `currency` or `symbol` so the unit does not depend on surrounding context.
|
|
@@ -24,6 +24,7 @@ export function Amount(props: Amount.Props) {
|
|
|
24
24
|
const {
|
|
25
25
|
className,
|
|
26
26
|
currency,
|
|
27
|
+
locale,
|
|
27
28
|
maxDecimals,
|
|
28
29
|
precise,
|
|
29
30
|
sign,
|
|
@@ -48,7 +49,7 @@ export function Amount(props: Amount.Props) {
|
|
|
48
49
|
})}
|
|
49
50
|
>
|
|
50
51
|
{sign}
|
|
51
|
-
{format({ currency, maxDecimals, precise, value })}
|
|
52
|
+
{format({ currency, locale, maxDecimals, precise, value })}
|
|
52
53
|
{symbol && <span {...styles.symbol()}> {symbol}</span>}
|
|
53
54
|
</span>
|
|
54
55
|
)
|
|
@@ -57,6 +58,13 @@ export function Amount(props: Amount.Props) {
|
|
|
57
58
|
export namespace Amount {
|
|
58
59
|
export type Props = Omit<ComponentPropsWithRef<'span'>, 'children'> &
|
|
59
60
|
zyzz.Props.Variants<typeof styles.amount> & {
|
|
61
|
+
/**
|
|
62
|
+
* BCP 47 tag, such as `de-DE`, for grouping, decimal marks, and currency symbols. The default
|
|
63
|
+
* does not depend on the runtime locale, so server and browser render the same text. A tag
|
|
64
|
+
* that `Intl` rejects, such as `""` or `"en_US"`, falls back to `en-US` instead of throwing.
|
|
65
|
+
* @default 'en-US'
|
|
66
|
+
*/
|
|
67
|
+
locale?: string | undefined
|
|
60
68
|
/**
|
|
61
69
|
* Caps the fraction digits shown. With `currency`, it never exceeds the currency's own digits
|
|
62
70
|
* and sets the exact count, so `0` renders `$5`.
|
|
@@ -88,29 +96,41 @@ export namespace Amount {
|
|
|
88
96
|
)
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
function format(
|
|
99
|
+
function format(
|
|
100
|
+
options: Pick<Amount.Props, 'currency' | 'locale' | 'maxDecimals' | 'precise' | 'value'>,
|
|
101
|
+
) {
|
|
92
102
|
const { currency, maxDecimals, precise, value } = options
|
|
93
103
|
// ES2023 formats decimal strings exactly. The signature keeps ES2022 libs compiling.
|
|
94
104
|
const exact = (formatter: Intl.NumberFormat) =>
|
|
95
105
|
(formatter.format as (value: number | string) => string)(value)
|
|
96
106
|
|
|
107
|
+
// `Intl.NumberFormat` throws for a malformed tag and replaces an unsupported one with the
|
|
108
|
+
// runtime locale, so both resolve to `en-US` here.
|
|
109
|
+
const locale = (() => {
|
|
110
|
+
try {
|
|
111
|
+
return Intl.NumberFormat.supportedLocalesOf(options.locale)[0] ?? 'en-US'
|
|
112
|
+
} catch {
|
|
113
|
+
return 'en-US'
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
|
|
97
117
|
if (currency) {
|
|
98
118
|
const money = { currency, style: 'currency' } as const
|
|
99
|
-
const max = new Intl.NumberFormat(
|
|
119
|
+
const max = new Intl.NumberFormat(locale, money).resolvedOptions().maximumFractionDigits!
|
|
100
120
|
const digits = Math.min(maxDecimals ?? max, max)
|
|
101
121
|
const number = Number(value)
|
|
102
122
|
const sub = number !== 0 && Math.abs(number) < 10 ** -digits
|
|
103
123
|
|
|
104
124
|
if (sub && precise)
|
|
105
125
|
return exact(
|
|
106
|
-
new Intl.NumberFormat(
|
|
126
|
+
new Intl.NumberFormat(locale, {
|
|
107
127
|
...money,
|
|
108
128
|
maximumFractionDigits: 20,
|
|
109
129
|
maximumSignificantDigits: 2,
|
|
110
130
|
}),
|
|
111
131
|
)
|
|
112
132
|
|
|
113
|
-
const formatter = new Intl.NumberFormat(
|
|
133
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
114
134
|
...money,
|
|
115
135
|
maximumFractionDigits: digits,
|
|
116
136
|
minimumFractionDigits: digits,
|
|
@@ -124,7 +144,7 @@ function format(options: Pick<Amount.Props, 'currency' | 'maxDecimals' | 'precis
|
|
|
124
144
|
const digits = Math.min(maxDecimals ?? fraction, fraction)
|
|
125
145
|
|
|
126
146
|
return exact(
|
|
127
|
-
new Intl.NumberFormat(
|
|
147
|
+
new Intl.NumberFormat(locale, {
|
|
128
148
|
maximumFractionDigits: digits,
|
|
129
149
|
minimumFractionDigits: typeof value === 'string' ? digits : 0,
|
|
130
150
|
}),
|
|
@@ -6,8 +6,21 @@ import { Input } from './Input.js'
|
|
|
6
6
|
import * as AmountContext from './internal/AmountContext.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Decimal amount entry. The value is a raw string
|
|
10
|
-
* not a non-negative decimal is rejected.
|
|
9
|
+
* Decimal amount entry. The value is a raw string with a dot separator and no grouping
|
|
10
|
+
* separators, and input that is not a non-negative decimal is rejected.
|
|
11
|
+
*
|
|
12
|
+
* The text around a comma decides whether it groups thousands or separates decimals:
|
|
13
|
+
*
|
|
14
|
+
* - With both a dot and a comma, the last one separates decimals and the other groups thousands:
|
|
15
|
+
* `1,250.50` and `1.250,50` are both `1250.50`.
|
|
16
|
+
* - Several commas group thousands: `1,250,000` is `1250000`.
|
|
17
|
+
* - A lone comma groups thousands after one to three digits that do not start with `0`, when
|
|
18
|
+
* exactly three digits follow it: `1,250` is `1250`.
|
|
19
|
+
* - Any other lone comma separates decimals: `1,5` is `1.5`, `0,250` is `0.250`, `1234,567` is
|
|
20
|
+
* `1234.567`, and `12,` is `12.`.
|
|
21
|
+
*
|
|
22
|
+
* The field shows a lone comma as typed until a dot or a second comma settles it, then shows the
|
|
23
|
+
* value.
|
|
11
24
|
*
|
|
12
25
|
* `Control` is a text input with `inputMode="decimal"`, so mobile keyboards show digits without
|
|
13
26
|
* the scroll and spinner behavior of `type="number"`.
|
|
@@ -46,10 +59,8 @@ export namespace AmountInput {
|
|
|
46
59
|
value={{
|
|
47
60
|
value,
|
|
48
61
|
setValue(next) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
setUncontrolled(clean)
|
|
52
|
-
onValueChange?.(clean)
|
|
62
|
+
setUncontrolled(next)
|
|
63
|
+
onValueChange?.(next)
|
|
53
64
|
},
|
|
54
65
|
}}
|
|
55
66
|
>
|
|
@@ -70,9 +81,11 @@ export namespace AmountInput {
|
|
|
70
81
|
/** Text field whose digits morph as the value changes. Motion stops under reduced motion. */
|
|
71
82
|
export function Control(props: Control.Props) {
|
|
72
83
|
const amount = AmountContext.use()
|
|
73
|
-
const [typed, setTyped] = useState<{ caret: number | null; value: string }>()
|
|
84
|
+
const [typed, setTyped] = useState<{ caret: number | null; text: string; value: string }>()
|
|
74
85
|
// Typed edits morph at the caret. Other changes, such as Max, roll digits by place value.
|
|
75
|
-
const
|
|
86
|
+
const edit = typed?.value === amount.value ? typed : undefined
|
|
87
|
+
const caret = edit?.caret ?? undefined
|
|
88
|
+
const text = edit?.text ?? amount.value
|
|
76
89
|
|
|
77
90
|
return (
|
|
78
91
|
<span {...styles.control()}>
|
|
@@ -82,15 +95,20 @@ export namespace AmountInput {
|
|
|
82
95
|
{...props}
|
|
83
96
|
{...styles.input({ className: props.className, style: props.style })}
|
|
84
97
|
onValueChange={(next, details) => {
|
|
98
|
+
const value = parse(next)
|
|
99
|
+
if (value === undefined) return
|
|
100
|
+
|
|
85
101
|
const input = details.event.target as HTMLInputElement
|
|
86
|
-
|
|
87
|
-
|
|
102
|
+
// The digits typed after a lone comma can still change its role, so it stays in place.
|
|
103
|
+
const lone = !next.includes('.') && next.split(',').length === 2
|
|
104
|
+
setTyped({ caret: input.selectionStart, text: lone ? next : value, value })
|
|
105
|
+
amount.setValue(value)
|
|
88
106
|
}}
|
|
89
107
|
type="text"
|
|
90
|
-
value={
|
|
108
|
+
value={text}
|
|
91
109
|
/>
|
|
92
110
|
<span aria-hidden="true" {...styles.morph()}>
|
|
93
|
-
<TextMorph {...(caret !== undefined && { cursorIndex: caret })}>{
|
|
111
|
+
<TextMorph {...(caret !== undefined && { cursorIndex: caret })}>{text}</TextMorph>
|
|
94
112
|
</span>
|
|
95
113
|
</span>
|
|
96
114
|
)
|
|
@@ -133,6 +151,24 @@ export namespace AmountInput {
|
|
|
133
151
|
export const Suffix = Input.Suffix
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
/** Returns the raw decimal for typed text, or `undefined` when it is not a non-negative decimal. */
|
|
155
|
+
function parse(text: string) {
|
|
156
|
+
const value = (() => {
|
|
157
|
+
const dot = text.lastIndexOf('.')
|
|
158
|
+
if (dot !== -1 && text.lastIndexOf(',') > dot)
|
|
159
|
+
return text.replaceAll('.', '').replaceAll(',', '.')
|
|
160
|
+
|
|
161
|
+
// Grouping needs a first group of one to three digits without a leading zero, so `0,250` and
|
|
162
|
+
// `1234,567` are decimals.
|
|
163
|
+
if (/^[^,.]*,[^,.]*$/.test(text) && !/^[1-9]\d{0,2},\d{3}$/.test(text))
|
|
164
|
+
return text.replace(',', '.')
|
|
165
|
+
|
|
166
|
+
return text.replaceAll(',', '')
|
|
167
|
+
})()
|
|
168
|
+
|
|
169
|
+
return /^\d*\.?\d*$/.test(value) ? value : undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
136
172
|
namespace styles {
|
|
137
173
|
export const control = style(
|
|
138
174
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as BaseUi from '@base-ui/react/button'
|
|
2
2
|
import type { ComponentPropsWithRef } from 'react'
|
|
3
3
|
import type * as zyzz from 'zyzz'
|
|
4
|
-
import { Close } from '../icons/
|
|
4
|
+
import { Close } from '../icons/Close.js'
|
|
5
5
|
import { style, variants } from '../zyzz.config.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -165,12 +165,11 @@ export function Calendar(props: Calendar.Props) {
|
|
|
165
165
|
if (!target) return
|
|
166
166
|
|
|
167
167
|
event.preventDefault()
|
|
168
|
-
const clamped =
|
|
169
|
-
min !== undefined && key(target) < key(min)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
: target
|
|
168
|
+
const clamped = (() => {
|
|
169
|
+
if (min !== undefined && key(target) < key(min)) return min
|
|
170
|
+
if (max !== undefined && key(target) > key(max)) return max
|
|
171
|
+
return target
|
|
172
|
+
})()
|
|
174
173
|
pendingFocus.current = true
|
|
175
174
|
setFocused(clamped)
|
|
176
175
|
if (!sameMonth(clamped, month)) showMonth(clamped)
|
|
@@ -2,7 +2,10 @@ import * as BaseUi from '@base-ui/react/button'
|
|
|
2
2
|
import { createContext, useContext } from 'react'
|
|
3
3
|
import type { ComponentPropsWithRef } from 'react'
|
|
4
4
|
import type * as zyzz from 'zyzz'
|
|
5
|
-
import { AlertCircle
|
|
5
|
+
import { AlertCircle } from '../icons/AlertCircle.js'
|
|
6
|
+
import { AlertTriangle } from '../icons/AlertTriangle.js'
|
|
7
|
+
import { CheckCircle } from '../icons/CheckCircle.js'
|
|
8
|
+
import { InfoCircle } from '../icons/InfoCircle.js'
|
|
6
9
|
import { style, variants } from '../zyzz.config.js'
|
|
7
10
|
|
|
8
11
|
type Intent = NonNullable<zyzz.Props.Variants<typeof styles.note>['intent']>
|
|
@@ -332,7 +332,7 @@ namespace styles {
|
|
|
332
332
|
'&[aria-invalid="true"]::before': { borderColor: 'red.800' },
|
|
333
333
|
'&[aria-invalid="true"]:focus-visible': { outlineColor: 'red.800' },
|
|
334
334
|
'&:focus-visible::before': { borderColor: 'transparent !custom' },
|
|
335
|
-
'&[data-placeholder]': { color: 'gray.
|
|
335
|
+
'&[data-placeholder]': { color: 'gray.900' },
|
|
336
336
|
},
|
|
337
337
|
'@media (forced-colors: active)': {
|
|
338
338
|
outlineStyle: 'none !custom',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as BaseUi from '@base-ui/react/dialog'
|
|
2
2
|
import type { ComponentPropsWithRef } from 'react'
|
|
3
3
|
import type * as zyzz from 'zyzz'
|
|
4
|
-
import
|
|
4
|
+
import { Close as CloseIcon } from '../icons/Close.js'
|
|
5
5
|
import { style } from '../zyzz.config.js'
|
|
6
6
|
import { Button } from './Button.js'
|
|
7
7
|
|
|
@@ -90,7 +90,7 @@ export namespace Dialog {
|
|
|
90
90
|
<Button shape="icon" size="medium" variant="secondary" {...styles.close()} />
|
|
91
91
|
}
|
|
92
92
|
>
|
|
93
|
-
<
|
|
93
|
+
<CloseIcon />
|
|
94
94
|
</BaseUi.Dialog.Close>
|
|
95
95
|
)}
|
|
96
96
|
</BaseUi.Dialog.Popup>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as BaseUi from '@base-ui/react/drawer'
|
|
2
2
|
import type { ComponentPropsWithRef } from 'react'
|
|
3
3
|
import type * as zyzz from 'zyzz'
|
|
4
|
-
import
|
|
4
|
+
import { Close as CloseIcon } from '../icons/Close.js'
|
|
5
5
|
import { style } from '../zyzz.config.js'
|
|
6
6
|
import { Button } from './Button.js'
|
|
7
7
|
import { styles as dialog } from './Dialog.js'
|
|
@@ -107,7 +107,7 @@ export namespace Drawer {
|
|
|
107
107
|
<Button shape="icon" size="medium" variant="secondary" {...dialog.close()} />
|
|
108
108
|
}
|
|
109
109
|
>
|
|
110
|
-
<
|
|
110
|
+
<CloseIcon />
|
|
111
111
|
</BaseUi.Drawer.Close>
|
|
112
112
|
)}
|
|
113
113
|
</BaseUi.Drawer.Popup>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as BaseUi from '@base-ui/react/field'
|
|
2
|
-
import { type ComponentPropsWithRef, useContext } from 'react'
|
|
2
|
+
import { type ComponentPropsWithRef, useContext, useRef, useState } from 'react'
|
|
3
3
|
import type * as zyzz from 'zyzz'
|
|
4
4
|
import { style } from '../zyzz.config.js'
|
|
5
5
|
import * as FieldVariant from './internal/FieldVariant.js'
|
|
@@ -37,21 +37,56 @@ export namespace Field {
|
|
|
37
37
|
* Owns validity, dirty, touched and filled state for one control and shares the variant with the
|
|
38
38
|
* controls inside. Without `variant`, it inherits the variant of an enclosing `Fieldset` or
|
|
39
39
|
* `Field`, falling back to `default`.
|
|
40
|
+
*
|
|
41
|
+
* When a native `form` submit finds the control invalid, such as an empty `required` input, the
|
|
42
|
+
* field validates and shows `Field.Error`. Outside `onBlur` mode, it then revalidates on each
|
|
43
|
+
* change, as inside Base UI's `Form`. The browser still moves focus to the first invalid control.
|
|
44
|
+
*
|
|
45
|
+
* `checkValidity()` and `reportValidity()` validate the field only after the user has interacted
|
|
46
|
+
* with the control, when the browser already matches it as `:user-invalid`.
|
|
40
47
|
*/
|
|
41
48
|
export function Root(props: Root.Props) {
|
|
42
|
-
const
|
|
49
|
+
const actions = useRef<BaseUi.Field.Root.Actions | null>(null)
|
|
50
|
+
const {
|
|
51
|
+
actionsRef = actions,
|
|
52
|
+
className,
|
|
53
|
+
onInvalidCapture,
|
|
54
|
+
size,
|
|
55
|
+
success = false,
|
|
56
|
+
validationMode,
|
|
57
|
+
variant,
|
|
58
|
+
vars,
|
|
59
|
+
...attributes
|
|
60
|
+
} = props
|
|
43
61
|
const inherited = useContext(FieldVariant.Context)
|
|
44
62
|
const resolved = variant ?? inherited ?? 'default'
|
|
63
|
+
const [submitted, setSubmitted] = useState(false)
|
|
45
64
|
|
|
46
65
|
return (
|
|
47
66
|
<FieldVariant.Context.Provider value={resolved}>
|
|
48
67
|
<BaseUi.Field.Root
|
|
49
68
|
{...attributes}
|
|
50
69
|
{...styles.root({ className, style: props.style, vars })}
|
|
70
|
+
actionsRef={actionsRef}
|
|
51
71
|
data-field=""
|
|
52
72
|
data-field-size={size}
|
|
53
73
|
data-success={success || undefined}
|
|
54
74
|
data-variant={resolved}
|
|
75
|
+
onInvalidCapture={(event) => {
|
|
76
|
+
onInvalidCapture?.(event)
|
|
77
|
+
|
|
78
|
+
// Base UI runs submit validation only inside its own Form. A native form fires `invalid`
|
|
79
|
+
// on each failing control instead, and the event does not bubble. `checkValidity()` and
|
|
80
|
+
// `reportValidity()` fire it too, but only a submit attempt marks the form's controls
|
|
81
|
+
// `:user-invalid` first. `invalid` targets only form controls.
|
|
82
|
+
if (!(event.target as Element).matches(':user-invalid')) return
|
|
83
|
+
|
|
84
|
+
actionsRef.current?.validate()
|
|
85
|
+
setSubmitted(true)
|
|
86
|
+
}}
|
|
87
|
+
// Base UI's Form counts submits to revalidate `onSubmit` fields on change. A native
|
|
88
|
+
// form has no count, so a failed submit switches the field to `onChange`.
|
|
89
|
+
validationMode={submitted && validationMode !== 'onBlur' ? 'onChange' : validationMode}
|
|
55
90
|
/>
|
|
56
91
|
</FieldVariant.Context.Provider>
|
|
57
92
|
)
|
|
@@ -262,7 +262,7 @@ namespace styles {
|
|
|
262
262
|
selectors: {
|
|
263
263
|
'&::-webkit-search-cancel-button': { appearance: 'none !custom', display: 'none' },
|
|
264
264
|
'&::placeholder': {
|
|
265
|
-
color: 'grayAlpha.
|
|
265
|
+
color: 'grayAlpha.900',
|
|
266
266
|
transitionDuration: '160ms !custom',
|
|
267
267
|
transitionProperty: 'opacity !custom',
|
|
268
268
|
transitionTimingFunction: 'snappy',
|
|
@@ -1,24 +1,60 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ComponentPropsWithRef, useSyncExternalStore } from 'react'
|
|
2
2
|
import type * as zyzz from 'zyzz'
|
|
3
3
|
import { style, variants } from '../zyzz.config.js'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
[
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// Each platform lists its modifiers in its own conventional order, as label and spoken name.
|
|
6
|
+
const modifiers = {
|
|
7
|
+
apple: [
|
|
8
|
+
['ctrl', '⌃', 'Control'],
|
|
9
|
+
['alt', '⌥', 'Option'],
|
|
10
|
+
['shift', '⇧', 'Shift'],
|
|
11
|
+
['meta', '⌘', 'Command'],
|
|
12
|
+
],
|
|
13
|
+
linux: [
|
|
14
|
+
['meta', 'Super', 'Super'],
|
|
15
|
+
['ctrl', 'Ctrl', 'Control'],
|
|
16
|
+
['alt', 'Alt', 'Alt'],
|
|
17
|
+
['shift', 'Shift', 'Shift'],
|
|
18
|
+
],
|
|
19
|
+
windows: [
|
|
20
|
+
['meta', 'Win', 'Windows'],
|
|
21
|
+
['ctrl', 'Ctrl', 'Control'],
|
|
22
|
+
['alt', 'Alt', 'Alt'],
|
|
23
|
+
['shift', 'Shift', 'Shift'],
|
|
24
|
+
],
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
// The server snapshot keeps server and hydration markup identical. React then re-renders with the
|
|
28
|
+
// browser's platform.
|
|
29
|
+
function usePlatform(): keyof typeof modifiers {
|
|
30
|
+
return useSyncExternalStore(
|
|
31
|
+
() => () => {},
|
|
32
|
+
() => {
|
|
33
|
+
if (/Macintosh|iPad|iPhone|iPod/.test(navigator.userAgent)) return 'apple'
|
|
34
|
+
return navigator.userAgent.includes('Windows') ? 'windows' : 'linux'
|
|
35
|
+
},
|
|
36
|
+
() => 'apple',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
11
39
|
|
|
12
40
|
/**
|
|
13
|
-
* Shows a key or shortcut in a native `kbd` element. Each modifier prop adds
|
|
14
|
-
* children
|
|
15
|
-
*
|
|
41
|
+
* Shows a key or shortcut in a native `kbd` element. Each modifier prop adds a label before the
|
|
42
|
+
* children in the platform's conventional order: glyphs ordered Control, Option, Shift, Command on
|
|
43
|
+
* Apple platforms, and names ordered Win (Super on Linux), Ctrl, Alt, Shift elsewhere. Labels are
|
|
44
|
+
* hidden from assistive tech, which reads the visually hidden name instead, so `<Kbd mod>K</Kbd>`
|
|
45
|
+
* reads as `Command K` on a Mac and `Control K` on Windows.
|
|
46
|
+
*
|
|
47
|
+
* Without `platform`, the server and hydration render Apple glyphs, then the client switches to the
|
|
48
|
+
* browser's platform. If the request identifies the platform, such as in the `Sec-CH-UA-Platform`
|
|
49
|
+
* header, map it to `apple`, `linux`, or `windows` and pass it as `platform`, so the markup never
|
|
50
|
+
* changes. Any other value falls back to the browser's platform.
|
|
16
51
|
*
|
|
17
52
|
* - Pass the main key as children and modifiers as props, rather than typing glyphs into children.
|
|
53
|
+
* - Use `mod` for shortcuts that take Command on Apple platforms and Ctrl elsewhere.
|
|
18
54
|
* - Single-character shortcuts need a way to turn them off or remap them.
|
|
19
55
|
*
|
|
20
56
|
* @example
|
|
21
|
-
* <Kbd
|
|
57
|
+
* <Kbd mod shift>
|
|
22
58
|
* P
|
|
23
59
|
* </Kbd>
|
|
24
60
|
*
|
|
@@ -27,16 +63,27 @@ const modifiers = [
|
|
|
27
63
|
* @see {@link https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html | WCAG 2.2 Info and Relationships}
|
|
28
64
|
*/
|
|
29
65
|
export function Kbd(props: Kbd.Props) {
|
|
30
|
-
const
|
|
31
|
-
const
|
|
66
|
+
const detected = usePlatform()
|
|
67
|
+
const { alt, children, className, ctrl, meta, mod, platform, shift, size, vars, ...attributes } =
|
|
68
|
+
props
|
|
69
|
+
// Untyped data, such as a raw `Sec-CH-UA-Platform` header, can hold any value, including inherited
|
|
70
|
+
// keys such as `toString`. Every value outside `modifiers` falls back to detection.
|
|
71
|
+
const resolved =
|
|
72
|
+
platform !== undefined && Object.hasOwn(modifiers, platform) ? platform : detected
|
|
73
|
+
const active = {
|
|
74
|
+
alt,
|
|
75
|
+
ctrl: ctrl || (mod && resolved !== 'apple'),
|
|
76
|
+
meta: meta || (mod && resolved === 'apple'),
|
|
77
|
+
shift,
|
|
78
|
+
}
|
|
32
79
|
|
|
33
80
|
return (
|
|
34
81
|
<kbd {...attributes} {...styles.kbd({ className, size, style: props.style, vars })}>
|
|
35
|
-
{modifiers.map(
|
|
36
|
-
([key,
|
|
82
|
+
{modifiers[resolved].map(
|
|
83
|
+
([key, label, name]) =>
|
|
37
84
|
active[key] && (
|
|
38
85
|
<span key={key}>
|
|
39
|
-
<span aria-hidden="true">{
|
|
86
|
+
<span aria-hidden="true">{label}</span>
|
|
40
87
|
<span {...styles.hidden()}>{name} </span>
|
|
41
88
|
</span>
|
|
42
89
|
),
|
|
@@ -49,13 +96,22 @@ export function Kbd(props: Kbd.Props) {
|
|
|
49
96
|
export namespace Kbd {
|
|
50
97
|
export type Props = ComponentPropsWithRef<'kbd'> &
|
|
51
98
|
zyzz.Props.Variants<typeof styles.kbd> & {
|
|
52
|
-
/** Adds
|
|
99
|
+
/** Adds `⌥` (Option) on Apple platforms and Alt elsewhere. */
|
|
53
100
|
alt?: boolean | undefined
|
|
54
|
-
/** Adds
|
|
101
|
+
/** Adds `⌃` (Control) on Apple platforms and Ctrl elsewhere. */
|
|
55
102
|
ctrl?: boolean | undefined
|
|
56
|
-
/** Adds
|
|
103
|
+
/** Adds `⌘` (Command) on Apple platforms, Win on Windows, and Super on Linux. */
|
|
57
104
|
meta?: boolean | undefined
|
|
58
|
-
/** Adds
|
|
105
|
+
/** Adds the primary shortcut modifier: `⌘` (Command) on Apple platforms and Ctrl elsewhere. */
|
|
106
|
+
mod?: boolean | undefined
|
|
107
|
+
/**
|
|
108
|
+
* Selects the modifier labels, names and order. Defaults to the browser's platform, read after
|
|
109
|
+
* hydration, and to `apple` on the server. Browsers on other platforms, such as Android or
|
|
110
|
+
* ChromeOS, use the `linux` names. Any other value, such as a raw `Sec-CH-UA-Platform` header,
|
|
111
|
+
* falls back to the default.
|
|
112
|
+
*/
|
|
113
|
+
platform?: keyof typeof modifiers | undefined
|
|
114
|
+
/** Adds `⇧` (Shift) on Apple platforms and Shift elsewhere. */
|
|
59
115
|
shift?: boolean | undefined
|
|
60
116
|
}
|
|
61
117
|
}
|
|
@@ -306,7 +306,7 @@ export namespace styles {
|
|
|
306
306
|
|
|
307
307
|
export const groupLabel = style(
|
|
308
308
|
{
|
|
309
|
-
color: 'gray.
|
|
309
|
+
color: 'gray.900',
|
|
310
310
|
paddingBlock: '8',
|
|
311
311
|
paddingInline: '12',
|
|
312
312
|
typography: 'label.12',
|
|
@@ -424,7 +424,7 @@ export namespace styles {
|
|
|
424
424
|
|
|
425
425
|
export const shortcut = style(
|
|
426
426
|
{
|
|
427
|
-
color: 'gray.
|
|
427
|
+
color: 'gray.900',
|
|
428
428
|
marginInlineStart: 'auto !custom',
|
|
429
429
|
paddingInlineStart: '16',
|
|
430
430
|
typography: 'label.13',
|