@tanishqkancharla/maui 0.0.52 → 0.0.53

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.
@@ -1,14 +1,21 @@
1
+ import { type Ref } from "react";
1
2
  import { AriaNumberFieldProps, AriaSearchFieldProps, AriaTextFieldOptions } from "react-aria";
2
3
  type KeyboardHintProps = {
3
4
  /** A single-character shortcut displayed at the inline end while unfocused. */
4
5
  keyboardHint?: string;
5
6
  };
6
- export type TextFieldProps = AriaTextFieldOptions<"input"> & KeyboardHintProps;
7
+ type BaseTextFieldProps = AriaTextFieldOptions<"input"> & KeyboardHintProps;
8
+ export type TextFieldSize = "default" | "sm";
9
+ export type TextFieldProps = BaseTextFieldProps & {
10
+ /** `sm` matches `Button size="sm"` for dense rows such as tree items. */
11
+ size?: TextFieldSize;
12
+ ref?: Ref<HTMLInputElement>;
13
+ };
7
14
  export type SearchFieldProps = AriaSearchFieldProps & KeyboardHintProps & {
8
15
  "aria-keyshortcuts"?: string;
9
16
  };
10
17
  export declare function TextField(props: TextFieldProps): import("react").JSX.Element;
11
18
  export declare function SearchField(props: SearchFieldProps): import("react").JSX.Element;
12
19
  export declare function NumberField(props: AriaNumberFieldProps): import("react").JSX.Element;
13
- export declare function QuietTextField(props: TextFieldProps): import("react").JSX.Element;
20
+ export declare function QuietTextField(props: BaseTextFieldProps): import("react").JSX.Element;
14
21
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from "react";
3
- import { mergeProps, useButton, useLocale, useNumberField, useSearchField, useTextField, } from "react-aria";
3
+ import { mergeProps, useButton, useLocale, useNumberField, useObjectRef, useSearchField, useTextField, } from "react-aria";
4
4
  import { useNumberFieldState, useSearchFieldState } from "react-stately";
5
5
  import { defineVars, style, useStyles } from "purse-styles";
6
6
  import { LARGE_SCALE } from "../theme/dataScale";
@@ -38,6 +38,20 @@ const inputClass = style(inputText, focusRing("&:focus-visible", shadowVars.cont
38
38
  color: colors.gray[8],
39
39
  },
40
40
  });
41
+ const smallInputClass = style(spacing.padding({ x: 3 }), {
42
+ height: controlSize.smHeight,
43
+ paddingBlock: 0,
44
+ });
45
+ const fieldClass = style({
46
+ display: "flex",
47
+ flexDirection: "column",
48
+ gap: spacing.value(2),
49
+ width: "100%",
50
+ minWidth: 0,
51
+ });
52
+ const errorClass = style(text({ size: "xs", fontWeight: 400, color: "highContrast" }), {
53
+ color: "light-dark(#ce2c31, #e5484d)",
54
+ });
41
55
  const inputFrameClass = style({
42
56
  position: "relative",
43
57
  width: "100%",
@@ -67,16 +81,28 @@ function KeyboardHint(props) {
67
81
  return (_jsx(Kbd, { variant: "quiet", className: className, "aria-hidden": "true", children: props.children }));
68
82
  }
69
83
  export function TextField(props) {
70
- const { keyboardHint, ...fieldProps } = props;
71
- const ref = useRef(null);
72
- const { inputProps } = useTextField(fieldProps, ref);
84
+ const { keyboardHint, size = "default", ref: forwardedRef, ...fieldProps } = props;
85
+ const ref = useObjectRef(forwardedRef);
86
+ const { inputProps, errorMessageProps, isInvalid, validationErrors, validationDetails, } = useTextField(fieldProps, ref);
73
87
  const { isFocused, focusProps } = useKeyboardHint();
74
88
  const frameClassName = useStyles(inputFrameClass);
75
- const className = useStyles(inputClass, keyboardHint ? inputWithHintClass : undefined);
89
+ const fieldClassName = useStyles(fieldClass);
90
+ const errorClassName = useStyles(errorClass);
91
+ const className = useStyles(inputClass, size === "sm" && smallInputClass, keyboardHint ? inputWithHintClass : undefined);
92
+ const errorMessage = typeof fieldProps.errorMessage === "function"
93
+ ? fieldProps.errorMessage({
94
+ isInvalid,
95
+ validationErrors,
96
+ validationDetails,
97
+ })
98
+ : fieldProps.errorMessage;
76
99
  const input = (_jsx("input", { className: className, ref: ref, ...mergeProps(inputProps, focusProps) }));
77
- if (!keyboardHint)
78
- return input;
79
- return (_jsxs("div", { className: frameClassName, children: [input, !isFocused ? _jsx(KeyboardHint, { children: keyboardHint }) : null] }));
100
+ const control = keyboardHint ? (_jsxs("div", { className: frameClassName, children: [input, !isFocused ? _jsx(KeyboardHint, { children: keyboardHint }) : null] })) : (input);
101
+ const showError = isInvalid && errorMessage != null && errorMessage !== "";
102
+ // Keep one wrapper in every state so the input is not remounted, and does
103
+ // not lose focus, when the error appears or disappears while typing. Spans
104
+ // keep the field valid inside a <label>.
105
+ return (_jsxs("span", { className: fieldClassName, children: [control, showError ? (_jsx("span", { ...errorMessageProps, className: errorClassName, children: errorMessage })) : null] }));
80
106
  }
81
107
  const searchFieldSize = defineVars({
82
108
  clearSize: { default: "16px", [LARGE_SCALE]: "40px" },
package/dist/maui.d.ts CHANGED
@@ -15,7 +15,7 @@ export { flex, flexItem, grid, gridItem, type AlignItems, type JustifyContent }
15
15
  export { text, monospace, baseTextStyle, fontFamily, monoFontFamily, monoFontStyle, type TextSize, type TextWeight, type TextColor, type TextOptions, } from "./tokens/text";
16
16
  export { avatar } from "./tokens/avatar";
17
17
  export { motion, motionDurationMs, motionEasing, motionStreamDurationMs, overlayMotion, } from "./tokens/motion";
18
- export { iconSizeValues, sizingTokens as sizing, type IconSize, } from "./tokens/sizing";
18
+ export { controlSize, iconSizeValues, sizingTokens as sizing, type IconSize, } from "./tokens/sizing";
19
19
  export { prose, proseRhythm, proseHtml, proseStreamingMarkers, type ProseSize, } from "./tokens/prose";
20
20
  export { visuallyHidden } from "./tokens/visuallyHidden";
21
21
  export { Avatar } from "./components/Avatar";
@@ -27,7 +27,7 @@ export { Checkbox } from "./components/Checkbox";
27
27
  export { Switch } from "./components/Switch";
28
28
  export { Slider } from "./components/Slider";
29
29
  export { RadioOptionGroup, RadioOption } from "./components/Radio";
30
- export { TextField, SearchField, NumberField, QuietTextField, type TextFieldProps, type SearchFieldProps, } from "./components/Input";
30
+ export { TextField, SearchField, NumberField, QuietTextField, type TextFieldProps, type TextFieldSize, type SearchFieldProps, } from "./components/Input";
31
31
  export { Select, SelectItem, type SelectProps, type SelectItemProps, } from "./components/Select";
32
32
  export { Dialog, DialogActions, DialogBody, DialogTitle, type DialogActionsProps, type DialogBodyProps, type DialogProps, type DialogSize, type DialogTitleProps, } from "./components/Dialog";
33
33
  export { Tooltip } from "./components/Tooltip";
package/dist/maui.js CHANGED
@@ -17,7 +17,7 @@ export { flex, flexItem, grid, gridItem } from "./tokens/layout";
17
17
  export { text, monospace, baseTextStyle, fontFamily, monoFontFamily, monoFontStyle, } from "./tokens/text";
18
18
  export { avatar } from "./tokens/avatar";
19
19
  export { motion, motionDurationMs, motionEasing, motionStreamDurationMs, overlayMotion, } from "./tokens/motion";
20
- export { iconSizeValues, sizingTokens as sizing, } from "./tokens/sizing";
20
+ export { controlSize, iconSizeValues, sizingTokens as sizing, } from "./tokens/sizing";
21
21
  export { prose, proseRhythm, proseHtml, proseStreamingMarkers, } from "./tokens/prose";
22
22
  export { visuallyHidden } from "./tokens/visuallyHidden";
23
23
  // Components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/maui",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "Maui design system",
5
5
  "type": "module",
6
6
  "main": "./dist/maui.js",
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gallery: `/components/form-controls`. Import from `"maui"`. [Select](select.md) has its own page.
4
4
 
5
- All fields are 28px tall, full width of the parent, `shadow.control` except `QuietTextField`. They take React Aria field props (`value`, `onChange`, `placeholder`, `isDisabled`, `isInvalid`, `aria-label`, `id`, …).
5
+ All fields are 28px tall by default, full width of the parent, `shadow.control` except `QuietTextField`. They take React Aria field props (`value`, `onChange`, `placeholder`, `isDisabled`, `isInvalid`, `aria-label`, `id`, …).
6
6
 
7
7
  ```tsx
8
8
  <TextField aria-label="Title" placeholder="Title" value={v} onChange={setV} keyboardHint="T" />
@@ -13,6 +13,16 @@ All fields are 28px tall, full width of the parent, `shadow.control` except `Qui
13
13
 
14
14
  `NumberField` uses tabular numerals so the value does not shift as you step. Invalid (unfocused) adds a red 1px ring on top of `shadow.control`. Placeholders are italic `gray[8]`. Cap width in the parent (`maxWidth: 240px` is the gallery default).
15
15
 
16
+ `TextField` also accepts:
17
+
18
+ - `size="sm"` — 24px tall with tighter padding, matching `Button size="sm"`, for dense rows such as tree items.
19
+ - `ref` — the underlying `<input>`, for focus, blur, or selection.
20
+ - `errorMessage` — shown below the field while `isInvalid` is true and linked to the input with `aria-describedby`.
21
+
22
+ ```tsx
23
+ <TextField aria-label="Name" size="sm" value={name} onChange={setName} isInvalid={invalid} errorMessage="Use a name without slashes." />
24
+ ```
25
+
16
26
  `TextField`, `SearchField`, and `QuietTextField` accept `keyboardHint`, a single-character shortcut rendered at the inline end while the field is unfocused. The hint is presentational; the consuming app owns the keyboard handler. `SearchField` hides it when the query is non-empty so the clear button can use the same space.
17
27
 
18
28
  ## `Checkbox`
@@ -225,6 +225,8 @@ ease-in-out. Streaming markdown uses `motionStreamDurationMs`.
225
225
 
226
226
  Stroke/fill is `currentColor`. Pass `size` on the icon component; optional `width` / `height` override the box.
227
227
 
228
+ `controlSize.height` (28px) and `controlSize.smHeight` (24px) are the control heights used by `Button` and `TextField`. Both become 40px at large scale. Use them to size a slot that must line up with a control, such as an icon next to a `TextField size="sm"`.
229
+
228
230
  ## Avatar tokens
229
231
 
230
232
  `avatar.green` / `avatar.orange` / `avatar.pink` each have `background` and `foreground`. The `Avatar` component also uses `accent[4]`/`[11]`. You rarely need these tokens directly.
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from "react"
1
+ import { useRef, useState, type Ref } from "react"
2
2
  import {
3
3
  AriaNumberFieldProps,
4
4
  AriaSearchFieldProps,
@@ -7,6 +7,7 @@ import {
7
7
  useButton,
8
8
  useLocale,
9
9
  useNumberField,
10
+ useObjectRef,
10
11
  useSearchField,
11
12
  useTextField,
12
13
  } from "react-aria"
@@ -61,12 +62,38 @@ const inputClass = style(
61
62
  },
62
63
  )
63
64
 
65
+ const smallInputClass = style(spacing.padding({ x: 3 }), {
66
+ height: controlSize.smHeight,
67
+ paddingBlock: 0,
68
+ })
69
+
70
+ const fieldClass = style({
71
+ display: "flex",
72
+ flexDirection: "column",
73
+ gap: spacing.value(2),
74
+ width: "100%",
75
+ minWidth: 0,
76
+ })
77
+
78
+ const errorClass = style(
79
+ text({ size: "xs", fontWeight: 400, color: "highContrast" }),
80
+ {
81
+ color: "light-dark(#ce2c31, #e5484d)",
82
+ },
83
+ )
84
+
64
85
  type KeyboardHintProps = {
65
86
  /** A single-character shortcut displayed at the inline end while unfocused. */
66
87
  keyboardHint?: string
67
88
  }
68
89
 
69
- export type TextFieldProps = AriaTextFieldOptions<"input"> & KeyboardHintProps
90
+ type BaseTextFieldProps = AriaTextFieldOptions<"input"> & KeyboardHintProps
91
+ export type TextFieldSize = "default" | "sm"
92
+ export type TextFieldProps = BaseTextFieldProps & {
93
+ /** `sm` matches `Button size="sm"` for dense rows such as tree items. */
94
+ size?: TextFieldSize
95
+ ref?: Ref<HTMLInputElement>
96
+ }
70
97
  export type SearchFieldProps = AriaSearchFieldProps &
71
98
  KeyboardHintProps & {
72
99
  "aria-keyshortcuts"?: string
@@ -113,15 +140,37 @@ function KeyboardHint(props: { children: string }) {
113
140
  }
114
141
 
115
142
  export function TextField(props: TextFieldProps) {
116
- const { keyboardHint, ...fieldProps } = props
117
- const ref = useRef<HTMLInputElement>(null)
118
- const { inputProps } = useTextField(fieldProps, ref)
143
+ const {
144
+ keyboardHint,
145
+ size = "default",
146
+ ref: forwardedRef,
147
+ ...fieldProps
148
+ } = props
149
+ const ref = useObjectRef(forwardedRef)
150
+ const {
151
+ inputProps,
152
+ errorMessageProps,
153
+ isInvalid,
154
+ validationErrors,
155
+ validationDetails,
156
+ } = useTextField(fieldProps, ref)
119
157
  const { isFocused, focusProps } = useKeyboardHint()
120
158
  const frameClassName = useStyles(inputFrameClass)
159
+ const fieldClassName = useStyles(fieldClass)
160
+ const errorClassName = useStyles(errorClass)
121
161
  const className = useStyles(
122
162
  inputClass,
163
+ size === "sm" && smallInputClass,
123
164
  keyboardHint ? inputWithHintClass : undefined,
124
165
  )
166
+ const errorMessage =
167
+ typeof fieldProps.errorMessage === "function"
168
+ ? fieldProps.errorMessage({
169
+ isInvalid,
170
+ validationErrors,
171
+ validationDetails,
172
+ })
173
+ : fieldProps.errorMessage
125
174
  const input = (
126
175
  <input
127
176
  className={className}
@@ -129,14 +178,29 @@ export function TextField(props: TextFieldProps) {
129
178
  {...mergeProps(inputProps, focusProps)}
130
179
  />
131
180
  )
132
-
133
- if (!keyboardHint) return input
134
-
135
- return (
181
+ const control = keyboardHint ? (
136
182
  <div className={frameClassName}>
137
183
  {input}
138
184
  {!isFocused ? <KeyboardHint>{keyboardHint}</KeyboardHint> : null}
139
185
  </div>
186
+ ) : (
187
+ input
188
+ )
189
+
190
+ const showError = isInvalid && errorMessage != null && errorMessage !== ""
191
+
192
+ // Keep one wrapper in every state so the input is not remounted, and does
193
+ // not lose focus, when the error appears or disappears while typing. Spans
194
+ // keep the field valid inside a <label>.
195
+ return (
196
+ <span className={fieldClassName}>
197
+ {control}
198
+ {showError ? (
199
+ <span {...errorMessageProps} className={errorClassName}>
200
+ {errorMessage}
201
+ </span>
202
+ ) : null}
203
+ </span>
140
204
  )
141
205
  }
142
206
 
@@ -362,7 +426,7 @@ const quietInputClass = style(
362
426
  },
363
427
  )
364
428
 
365
- export function QuietTextField(props: TextFieldProps) {
429
+ export function QuietTextField(props: BaseTextFieldProps) {
366
430
  const { keyboardHint, ...fieldProps } = props
367
431
  const ref = useRef<HTMLInputElement>(null)
368
432
  const { inputProps } = useTextField(fieldProps, ref)
package/src/maui.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  overlayMotion,
42
42
  } from "./tokens/motion"
43
43
  export {
44
+ controlSize,
44
45
  iconSizeValues,
45
46
  sizingTokens as sizing,
46
47
  type IconSize,
@@ -82,6 +83,7 @@ export {
82
83
  NumberField,
83
84
  QuietTextField,
84
85
  type TextFieldProps,
86
+ type TextFieldSize,
85
87
  type SearchFieldProps,
86
88
  } from "./components/Input"
87
89
  export {
@@ -17,6 +17,7 @@ export function FormControlsPage() {
17
17
  const [switchState, setSwitchState] = useState(false)
18
18
  const [checkboxState, setCheckboxState] = useState(false)
19
19
  const [textValue, setTextValue] = useState("")
20
+ const [smallTextValue, setSmallTextValue] = useState("notes/plan.md")
20
21
  const [searchValue, setSearchValue] = useState("")
21
22
  const [numberValue, setNumberValue] = useState(3)
22
23
  const [radioValue, setRadioValue] = useState("one")
@@ -37,6 +38,17 @@ export function FormControlsPage() {
37
38
  onChange={setTextValue}
38
39
  />
39
40
  </Flex>
41
+ <Flex column gap={3} style={{ maxWidth: "240px" }}>
42
+ <Label htmlFor="small-text-field">Small text field with error</Label>
43
+ <TextField
44
+ id="small-text-field"
45
+ size="sm"
46
+ value={smallTextValue}
47
+ onChange={setSmallTextValue}
48
+ isInvalid={smallTextValue.includes("/")}
49
+ errorMessage="Use a name without slashes."
50
+ />
51
+ </Flex>
40
52
  <Flex column gap={3} style={{ maxWidth: "240px" }}>
41
53
  <Label htmlFor="search-field">Search field</Label>
42
54
  <SearchField