@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.
Files changed (181) hide show
  1. package/README.md +36 -0
  2. package/dist/mobile/components/Avatar.d.ts +1 -1
  3. package/dist/mobile/components/Avatar.d.ts.map +1 -1
  4. package/dist/mobile/components/Avatar.js.map +1 -1
  5. package/dist/mobile/components/Button.d.ts +1 -1
  6. package/dist/mobile/components/Button.d.ts.map +1 -1
  7. package/dist/mobile/components/Button.js +5 -1
  8. package/dist/mobile/components/Button.js.map +1 -1
  9. package/dist/mobile/components/CodeInput.d.ts +1 -1
  10. package/dist/mobile/components/CodeInput.d.ts.map +1 -1
  11. package/dist/mobile/components/CodeInput.js.map +1 -1
  12. package/dist/mobile/components/IconButton.d.ts +1 -1
  13. package/dist/mobile/components/IconButton.d.ts.map +1 -1
  14. package/dist/mobile/components/IconButton.js +5 -1
  15. package/dist/mobile/components/IconButton.js.map +1 -1
  16. package/dist/mobile/components/Skeleton.d.ts.map +1 -1
  17. package/dist/mobile/components/Skeleton.js.map +1 -1
  18. package/dist/mobile/components/Spinner.d.ts +1 -1
  19. package/dist/mobile/components/Spinner.d.ts.map +1 -1
  20. package/dist/mobile/components/Spinner.js.map +1 -1
  21. package/dist/mobile/components/TextButton.d.ts.map +1 -1
  22. package/dist/mobile/components/TextButton.js +7 -2
  23. package/dist/mobile/components/TextButton.js.map +1 -1
  24. package/dist/mobile/components/TextInput.d.ts.map +1 -1
  25. package/dist/mobile/components/TextInput.js +1 -1
  26. package/dist/mobile/components/TextInput.js.map +1 -1
  27. package/dist/oxlint/index.d.ts +43 -0
  28. package/dist/oxlint/index.d.ts.map +1 -0
  29. package/dist/oxlint/index.js +68 -0
  30. package/dist/oxlint/index.js.map +1 -0
  31. package/dist/oxlint/internal/Imports.d.ts +22 -0
  32. package/dist/oxlint/internal/Imports.d.ts.map +1 -0
  33. package/dist/oxlint/internal/Imports.js +52 -0
  34. package/dist/oxlint/internal/Imports.js.map +1 -0
  35. package/dist/oxlint/rules/NoCrossSurface.d.ts +16 -0
  36. package/dist/oxlint/rules/NoCrossSurface.d.ts.map +1 -0
  37. package/dist/oxlint/rules/NoCrossSurface.js +55 -0
  38. package/dist/oxlint/rules/NoCrossSurface.js.map +1 -0
  39. package/dist/oxlint/rules/NoInternalImports.d.ts +8 -0
  40. package/dist/oxlint/rules/NoInternalImports.d.ts.map +1 -0
  41. package/dist/oxlint/rules/NoInternalImports.js +22 -0
  42. package/dist/oxlint/rules/NoInternalImports.js.map +1 -0
  43. package/dist/oxlint/rules/NoRawValues.d.ts +9 -0
  44. package/dist/oxlint/rules/NoRawValues.d.ts.map +1 -0
  45. package/dist/oxlint/rules/NoRawValues.js +73 -0
  46. package/dist/oxlint/rules/NoRawValues.js.map +1 -0
  47. package/dist/oxlint/rules/PreferComponent.d.ts +16 -0
  48. package/dist/oxlint/rules/PreferComponent.d.ts.map +1 -0
  49. package/dist/oxlint/rules/PreferComponent.js +92 -0
  50. package/dist/oxlint/rules/PreferComponent.js.map +1 -0
  51. package/dist/oxlint/rules/RequireAccessibleName.d.ts +9 -0
  52. package/dist/oxlint/rules/RequireAccessibleName.d.ts.map +1 -0
  53. package/dist/oxlint/rules/RequireAccessibleName.js +104 -0
  54. package/dist/oxlint/rules/RequireAccessibleName.js.map +1 -0
  55. package/dist/web/components/Amount.d.ts +10 -3
  56. package/dist/web/components/Amount.d.ts.map +1 -1
  57. package/dist/web/components/Amount.js +19 -9
  58. package/dist/web/components/Amount.js.map +1 -1
  59. package/dist/web/components/AmountInput.d.ts +15 -2
  60. package/dist/web/components/AmountInput.d.ts.map +1 -1
  61. package/dist/web/components/AmountInput.js +42 -11
  62. package/dist/web/components/AmountInput.js.map +1 -1
  63. package/dist/web/components/AmountInput.js.zyzz.json +1 -1
  64. package/dist/web/components/Autocomplete.js.zyzz.json +1 -1
  65. package/dist/web/components/Banner.js +1 -1
  66. package/dist/web/components/Calendar.d.ts.map +1 -1
  67. package/dist/web/components/Calendar.js +7 -5
  68. package/dist/web/components/Calendar.js.map +1 -1
  69. package/dist/web/components/Callout.d.ts.map +1 -1
  70. package/dist/web/components/Callout.js +4 -1
  71. package/dist/web/components/Callout.js.map +1 -1
  72. package/dist/web/components/Combobox.js.zyzz.json +1 -1
  73. package/dist/web/components/DatePicker.css +1 -1
  74. package/dist/web/components/DatePicker.d.ts +1 -1
  75. package/dist/web/components/DatePicker.js.zyzz.json +1 -1
  76. package/dist/web/components/Dialog.js +2 -2
  77. package/dist/web/components/Dialog.js.map +1 -1
  78. package/dist/web/components/Drawer.js +2 -2
  79. package/dist/web/components/Drawer.js.map +1 -1
  80. package/dist/web/components/Field.d.ts +7 -0
  81. package/dist/web/components/Field.d.ts.map +1 -1
  82. package/dist/web/components/Field.js +25 -3
  83. package/dist/web/components/Field.js.map +1 -1
  84. package/dist/web/components/Input.css +1 -1
  85. package/dist/web/components/Input.js.zyzz.json +1 -1
  86. package/dist/web/components/Kbd.d.ts +31 -9
  87. package/dist/web/components/Kbd.d.ts.map +1 -1
  88. package/dist/web/components/Kbd.js +55 -13
  89. package/dist/web/components/Kbd.js.map +1 -1
  90. package/dist/web/components/Menu.css +1 -1
  91. package/dist/web/components/Menu.js +1 -1
  92. package/dist/web/components/Menu.js.zyzz.json +1 -1
  93. package/dist/web/components/NumberField.css +1 -1
  94. package/dist/web/components/NumberField.js.zyzz.json +1 -1
  95. package/dist/web/components/Pagination.css +1 -1
  96. package/dist/web/components/Pagination.d.ts +1 -1
  97. package/dist/web/components/Pagination.js.zyzz.json +1 -1
  98. package/dist/web/components/Rows.css +1 -1
  99. package/dist/web/components/Rows.d.ts +1 -1
  100. package/dist/web/components/Rows.d.ts.map +1 -1
  101. package/dist/web/components/Rows.js +3 -3
  102. package/dist/web/components/Rows.js.map +1 -1
  103. package/dist/web/components/Rows.js.zyzz.json +1 -1
  104. package/dist/web/components/Select.css +1 -1
  105. package/dist/web/components/Select.js.zyzz.json +1 -1
  106. package/dist/web/components/Snippet.css +1 -1
  107. package/dist/web/components/Snippet.js +2 -2
  108. package/dist/web/components/Snippet.js.zyzz.json +1 -1
  109. package/dist/web/components/Table.css +1 -1
  110. package/dist/web/components/Table.d.ts +6 -3
  111. package/dist/web/components/Table.d.ts.map +1 -1
  112. package/dist/web/components/Table.js +5 -2
  113. package/dist/web/components/Table.js.map +1 -1
  114. package/dist/web/components/Table.js.zyzz.json +1 -1
  115. package/dist/web/components/Textarea.css +1 -1
  116. package/dist/web/components/Textarea.d.ts +1 -1
  117. package/dist/web/components/Textarea.js.zyzz.json +1 -1
  118. package/dist/web/components/Toast.d.ts.map +1 -1
  119. package/dist/web/components/Toast.js +5 -1
  120. package/dist/web/components/Toast.js.map +1 -1
  121. package/dist/web/components/internal/Field.css +1 -1
  122. package/dist/web/components/internal/Field.js.zyzz.json +1 -1
  123. package/dist/web/components/internal/InputGroup.d.ts.map +1 -1
  124. package/dist/web/components/internal/InputGroup.js.map +1 -1
  125. package/dist/web/components/internal/Listbox.css +1 -1
  126. package/dist/web/components/internal/Listbox.js.zyzz.json +1 -1
  127. package/dist/web/icons/BadgeX.d.ts.map +1 -1
  128. package/dist/web/icons/BadgeX.js.map +1 -1
  129. package/dist/web/icons/InfinityIcon.d.ts.map +1 -1
  130. package/dist/web/icons/InfinityIcon.js.map +1 -1
  131. package/dist/web/icons/LightMode.d.ts.map +1 -1
  132. package/dist/web/icons/LightMode.js.map +1 -1
  133. package/dist/web/icons/OctagonAlert.d.ts.map +1 -1
  134. package/dist/web/icons/OctagonAlert.js.map +1 -1
  135. package/dist/web/icons/Unlink.d.ts.map +1 -1
  136. package/dist/web/icons/Unlink.js.map +1 -1
  137. package/dist/web/index.js.zyzz.json +1 -1
  138. package/package.json +7 -1
  139. package/src/mobile/components/Avatar.tsx +2 -2
  140. package/src/mobile/components/Button.tsx +5 -2
  141. package/src/mobile/components/CodeInput.tsx +2 -2
  142. package/src/mobile/components/IconButton.tsx +5 -2
  143. package/src/mobile/components/Skeleton.tsx +1 -1
  144. package/src/mobile/components/Spinner.tsx +1 -1
  145. package/src/mobile/components/TextButton.tsx +6 -2
  146. package/src/mobile/components/TextInput.tsx +1 -1
  147. package/src/oxlint/index.ts +89 -0
  148. package/src/oxlint/internal/Imports.ts +68 -0
  149. package/src/oxlint/rules/NoCrossSurface.ts +71 -0
  150. package/src/oxlint/rules/NoInternalImports.ts +25 -0
  151. package/src/oxlint/rules/NoRawValues.ts +76 -0
  152. package/src/oxlint/rules/PreferComponent.ts +113 -0
  153. package/src/oxlint/rules/RequireAccessibleName.ts +132 -0
  154. package/src/web/components/Amount.tsx +29 -9
  155. package/src/web/components/AmountInput.tsx +48 -12
  156. package/src/web/components/Banner.tsx +1 -1
  157. package/src/web/components/Calendar.tsx +5 -6
  158. package/src/web/components/Callout.tsx +4 -1
  159. package/src/web/components/DatePicker.tsx +1 -1
  160. package/src/web/components/Dialog.tsx +2 -2
  161. package/src/web/components/Drawer.tsx +2 -2
  162. package/src/web/components/Field.tsx +37 -2
  163. package/src/web/components/Input.tsx +1 -1
  164. package/src/web/components/Kbd.tsx +76 -20
  165. package/src/web/components/Menu.tsx +2 -2
  166. package/src/web/components/NumberField.tsx +1 -1
  167. package/src/web/components/Pagination.tsx +1 -1
  168. package/src/web/components/Rows.tsx +1 -2
  169. package/src/web/components/Select.tsx +1 -1
  170. package/src/web/components/Snippet.tsx +1 -1
  171. package/src/web/components/Table.tsx +7 -3
  172. package/src/web/components/Textarea.tsx +1 -1
  173. package/src/web/components/Toast.tsx +5 -1
  174. package/src/web/components/internal/Field.ts +1 -1
  175. package/src/web/components/internal/InputGroup.ts +1 -1
  176. package/src/web/components/internal/Listbox.ts +2 -2
  177. package/src/web/icons/BadgeX.tsx +1 -1
  178. package/src/web/icons/InfinityIcon.tsx +1 -1
  179. package/src/web/icons/LightMode.tsx +1 -1
  180. package/src/web/icons/OctagonAlert.tsx +1 -1
  181. 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 the user's locale inside a `span` with tabular figures. `currency` formats
7
- * an ISO 4217 code as money, and `symbol` follows the number with a token symbol. The two are
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(options: Pick<Amount.Props, 'currency' | 'maxDecimals' | 'precise' | 'value'>) {
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(undefined, money).resolvedOptions().maximumFractionDigits!
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(undefined, {
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(undefined, {
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(undefined, {
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 without grouping commas, and input that is
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
- const clean = next.replaceAll(',', '')
50
- if (!/^\d*\.?\d*$/.test(clean)) return
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 caret = typed?.value === amount.value ? (typed.caret ?? undefined) : undefined
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
- setTyped({ caret: input.selectionStart, value: next.replaceAll(',', '') })
87
- amount.setValue(next)
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={amount.value}
108
+ value={text}
91
109
  />
92
110
  <span aria-hidden="true" {...styles.morph()}>
93
- <TextMorph {...(caret !== undefined && { cursorIndex: caret })}>{amount.value}</TextMorph>
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/index.js'
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
- ? min
171
- : max !== undefined && key(target) > key(max)
172
- ? max
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, AlertTriangle, CheckCircle, InfoCircle } from '../icons/index.js'
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.700' },
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 * as Icons from '../icons/index.js'
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
- <Icons.Close />
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 * as Icons from '../icons/index.js'
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
- <Icons.Close />
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 { className, size, success = false, variant, vars, ...attributes } = props
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.700',
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 { ComponentPropsWithRef } from 'react'
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
- const modifiers = [
6
- ['ctrl', '⌃', 'Control'],
7
- ['alt', '⌥', 'Option'],
8
- ['shift', '⇧', 'Shift'],
9
- ['meta', '⌘', 'Command'],
10
- ] as const
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 its glyph before the
14
- * children, always in the order Control, Option, Shift, Command. Glyphs are hidden from assistive
15
- * tech, which reads the visually hidden name instead, so `<Kbd meta>K</Kbd>` reads as `Command K`.
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 meta shift>
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 { alt, children, className, ctrl, meta, shift, size, vars, ...attributes } = props
31
- const active = { alt, ctrl, meta, shift }
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, glyph, name]) =>
82
+ {modifiers[resolved].map(
83
+ ([key, label, name]) =>
37
84
  active[key] && (
38
85
  <span key={key}>
39
- <span aria-hidden="true">{glyph}</span>
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 `⌥`, read as `Option`. */
99
+ /** Adds `⌥` (Option) on Apple platforms and Alt elsewhere. */
53
100
  alt?: boolean | undefined
54
- /** Adds `⌃`, read as `Control`. */
101
+ /** Adds `⌃` (Control) on Apple platforms and Ctrl elsewhere. */
55
102
  ctrl?: boolean | undefined
56
- /** Adds `⌘`, read as `Command`. */
103
+ /** Adds `⌘` (Command) on Apple platforms, Win on Windows, and Super on Linux. */
57
104
  meta?: boolean | undefined
58
- /** Adds `⇧`, read as `Shift`. */
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.700',
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.700',
427
+ color: 'gray.900',
428
428
  marginInlineStart: 'auto !custom',
429
429
  paddingInlineStart: '16',
430
430
  typography: 'label.13',
@@ -206,7 +206,7 @@ namespace styles {
206
206
  padding: '0',
207
207
  textAlign: 'center',
208
208
  selectors: {
209
- '&::placeholder': { color: 'gray.700' },
209
+ '&::placeholder': { color: 'gray.900' },
210
210
  '[data-typing] > &': { color: 'inherit !custom' },
211
211
  },
212
212
  },