@stonedogcode/style 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +699 -0
  4. package/package.json +95 -0
  5. package/src/components/DictationControls.tsx +141 -0
  6. package/src/components/DictationPrompt.tsx +78 -0
  7. package/src/components/StyledBox.tsx +174 -0
  8. package/src/components/StyledButton.tsx +144 -0
  9. package/src/components/StyledCollapsible.tsx +127 -0
  10. package/src/components/StyledDefinitionList.tsx +134 -0
  11. package/src/components/StyledFieldset.tsx +157 -0
  12. package/src/components/StyledFlex.tsx +13 -0
  13. package/src/components/StyledFooter.tsx +399 -0
  14. package/src/components/StyledFormLabel.tsx +141 -0
  15. package/src/components/StyledGrid.tsx +109 -0
  16. package/src/components/StyledGridItem.tsx +19 -0
  17. package/src/components/StyledHStack.tsx +145 -0
  18. package/src/components/StyledHeading.tsx +79 -0
  19. package/src/components/StyledHrRule.tsx +33 -0
  20. package/src/components/StyledIcon.tsx +172 -0
  21. package/src/components/StyledIconButton.tsx +135 -0
  22. package/src/components/StyledInputBool.tsx +81 -0
  23. package/src/components/StyledInputRadio.tsx +141 -0
  24. package/src/components/StyledInputSelect.tsx +115 -0
  25. package/src/components/StyledInputSlider.tsx +83 -0
  26. package/src/components/StyledInputText.tsx +146 -0
  27. package/src/components/StyledInputTextArea.tsx +119 -0
  28. package/src/components/StyledInputToggle.tsx +224 -0
  29. package/src/components/StyledList.tsx +188 -0
  30. package/src/components/StyledScrollbar.tsx +53 -0
  31. package/src/components/StyledSearch.tsx +78 -0
  32. package/src/components/StyledSeparator.tsx +38 -0
  33. package/src/components/StyledSidebar.tsx +555 -0
  34. package/src/components/StyledSimpleGrid.tsx +99 -0
  35. package/src/components/StyledSparkLine.tsx +119 -0
  36. package/src/components/StyledSpinner.tsx +91 -0
  37. package/src/components/StyledStack.tsx +62 -0
  38. package/src/components/StyledText.tsx +99 -0
  39. package/src/components/StyledTooltip.tsx +398 -0
  40. package/src/components/StyledVStack.tsx +143 -0
  41. package/src/components/TitleLogo.tsx +223 -0
  42. package/src/components/create-icon.tsx +66 -0
  43. package/src/components/create-intent-button.tsx +134 -0
  44. package/src/components/dictation.ts +71 -0
  45. package/src/components/intent-buttons.ts +154 -0
  46. package/src/config/can-hover.ts +75 -0
  47. package/src/config/density.ts +138 -0
  48. package/src/config/font-size.ts +113 -0
  49. package/src/config/intent-icons.tsx +116 -0
  50. package/src/config/logger.ts +60 -0
  51. package/src/config/style-config.tsx +263 -0
  52. package/src/config/types.ts +137 -0
  53. package/src/index.ts +259 -0
  54. package/src/preset/index.ts +243 -0
  55. package/src/preset/recipes/arrows.ts +29 -0
  56. package/src/preset/recipes/box.ts +122 -0
  57. package/src/preset/recipes/button.ts +161 -0
  58. package/src/preset/recipes/dl-list.ts +109 -0
  59. package/src/preset/recipes/drawer.ts +125 -0
  60. package/src/preset/recipes/form.ts +95 -0
  61. package/src/preset/recipes/icon-button.ts +161 -0
  62. package/src/preset/recipes/icon.ts +34 -0
  63. package/src/preset/recipes/input-bool.ts +184 -0
  64. package/src/preset/recipes/input-dropdown.ts +93 -0
  65. package/src/preset/recipes/input-radio.ts +158 -0
  66. package/src/preset/recipes/input-surface.ts +152 -0
  67. package/src/preset/recipes/input-text.ts +17 -0
  68. package/src/preset/recipes/list.ts +196 -0
  69. package/src/preset/recipes/menu.ts +28 -0
  70. package/src/preset/recipes/separator.ts +89 -0
  71. package/src/preset/recipes/stack.ts +89 -0
  72. package/src/preset/recipes/striped.ts +34 -0
  73. package/src/preset/recipes/text.ts +41 -0
  74. package/src/preset/recipes/tooltip.ts +77 -0
  75. package/src/preset/semantic-variables.ts +283 -0
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { inputRadioRootRecipe } from "styled-system/recipes";
5
+ import { useResolvedVariant } from "../config/style-config";
6
+
7
+ /**
8
+ * A group of radio buttons — pick exactly one.
9
+ *
10
+ * Reach for this over a set of checkboxes when the options are mutually
11
+ * exclusive, and over a select when there are few enough to show at once.
12
+ * Radios put every choice in front of the reader, which matters most for
13
+ * someone who finds a dropdown's hidden list hard to hold in mind.
14
+ *
15
+ * ## It is announced as a group (NEH-167)
16
+ *
17
+ * The originating version rendered bare labelled inputs. A shared `name` makes
18
+ * the browser treat them as one group for arrow-key navigation, so it *worked*
19
+ * — but nothing named the group, so a screen reader announced three unrelated
20
+ * radios and never said what the choice was about. `role="radiogroup"` plus a
21
+ * `label` fixes that, and the label is the one prop worth insisting on here.
22
+ *
23
+ * ## Two API repairs
24
+ *
25
+ * **`renderItem` is optional now.** It was required, and every call site passed
26
+ * `(item) => item.label` — the identity function, written out. `RadioItem`
27
+ * already carries a label, so that is the default; pass `renderItem` only when
28
+ * an option needs more than its text (a price, a badge, a description).
29
+ *
30
+ * **The ref no longer goes to the inputs.** It was assigned inside the item
31
+ * loop, so each item overwrote the last and a caller got a handle on the final
32
+ * radio rather than the group or the selection — reliably the wrong element.
33
+ * It now points at the group container. Nothing was using it.
34
+ *
35
+ * The container is a plain `<div>` rather than `StyledBox`, which is what the
36
+ * original used. `StyledBox` wraps its children in an inner element unless told
37
+ * not to, and that element would sit between the `radiogroup` and its radios —
38
+ * a group whose children are not its children is exactly the shape assistive
39
+ * tech mis-reports. The recipe's `root` slot supplies the layout either way.
40
+ */
41
+
42
+ export interface RadioItem {
43
+ value: string;
44
+ label: React.ReactNode;
45
+ disabled?: boolean;
46
+ }
47
+
48
+ export interface StyledInputRadioProps
49
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
50
+ items: RadioItem[];
51
+ /** Optional custom rendering. Defaults to the item's own label. */
52
+ renderItem?: (item: RadioItem) => React.ReactNode;
53
+ name?: string;
54
+ value?: string;
55
+ onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
56
+ variant?: RadioVariant;
57
+ size?: "sm" | "md" | "lg";
58
+ /**
59
+ * What the choice is about — "Billing cycle", not "Monthly". Without it the
60
+ * group has no accessible name. Use `aria-labelledby` instead when a visible
61
+ * heading already says it.
62
+ */
63
+ label?: string;
64
+ }
65
+
66
+ /** What `inputRadioRootRecipe` defines. */
67
+ export const RADIO_VARIANTS = [
68
+ "none",
69
+ "outline",
70
+ "solid",
71
+ "aurora",
72
+ "glass",
73
+ "matte",
74
+ "ghost",
75
+ ] as const;
76
+
77
+ export type RadioVariant = (typeof RADIO_VARIANTS)[number];
78
+
79
+ const StyledInputRadio = React.forwardRef<HTMLDivElement, StyledInputRadioProps>(
80
+ function StyledInputRadio(
81
+ { items, renderItem, name, value, onChange, variant, size, label, ...props },
82
+ ref,
83
+ ) {
84
+ const resolved = useResolvedVariant(variant, RADIO_VARIANTS);
85
+ const { root, item, input, control, indicator } = inputRadioRootRecipe({
86
+ variant: resolved,
87
+ ...(size ? { size } : {}),
88
+ });
89
+
90
+ const id = React.useId();
91
+
92
+ return (
93
+ <div
94
+ ref={ref}
95
+ role="radiogroup"
96
+ {...(label ? { "aria-label": label } : {})}
97
+ {...props}
98
+ className={root}
99
+ >
100
+ {items.map((radio, index) => {
101
+ // The value can be anything, including whitespace, so it is not safe
102
+ // as an id on its own. Index keeps it unique even for duplicates.
103
+ const safeValue = (radio.value || "unknown").toString().replace(/\s+/g, "_");
104
+ const inputId = `${id}-${index}-${safeValue}`;
105
+
106
+ return (
107
+ <label
108
+ key={inputId}
109
+ htmlFor={inputId}
110
+ className={item}
111
+ // The transparent input is absolutely positioned, so its label
112
+ // has to be the containing block or it lands somewhere else on
113
+ // the page entirely.
114
+ style={{ position: "relative" }}
115
+ data-checked={value === radio.value ? "" : undefined}
116
+ >
117
+ <input
118
+ id={inputId}
119
+ type="radio"
120
+ name={name}
121
+ value={radio.value}
122
+ checked={value === radio.value}
123
+ disabled={radio.disabled}
124
+ onChange={onChange}
125
+ className={input}
126
+ />
127
+ <div className={control}>
128
+ <div className={indicator} />
129
+ </div>
130
+ {renderItem ? renderItem(radio) : radio.label}
131
+ </label>
132
+ );
133
+ })}
134
+ </div>
135
+ );
136
+ },
137
+ );
138
+
139
+ StyledInputRadio.displayName = "StyledInputRadio";
140
+
141
+ export default StyledInputRadio;
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+ import { inputDropdownRecipe } from "styled-system/recipes";
7
+ import { useFontSizeProfile, useResolvedVariant } from "../config/style-config";
8
+ import { fontSizeMap } from "../config/font-size";
9
+
10
+ /**
11
+ * A dropdown, built on the **native** `<select>`.
12
+ *
13
+ * ## Why native, when the originating app has a custom one
14
+ *
15
+ * HopperGuard ships a compound dropdown built on `@floating-ui/react` — a
16
+ * portal, a focus manager, twelve exported sub-components. It was not ported,
17
+ * and the reasons compound:
18
+ *
19
+ * - **It costs every consumer a positioning library.** This package has exactly
20
+ * one runtime dependency (`csstype`), and each addition is a constraint
21
+ * imposed on three products to serve one.
22
+ * - **The native control is more accessible than a good reimplementation.** It
23
+ * is the platform's own listbox: type-ahead, `Home`/`End`, screen-reader
24
+ * announcement of "3 of 12", and on a phone the OS picker rather than a
25
+ * scrolling div. A custom dropdown has to rebuild all of that and usually
26
+ * rebuilds most of it.
27
+ * - **It submits.** A native select inside a `<form>` posts its value with no
28
+ * JavaScript. optima-filings's forms are server actions, so a custom
29
+ * dropdown there would need a hidden mirror input — machinery whose only
30
+ * purpose is to undo the choice to be custom.
31
+ *
32
+ * What you give up is styling the open list, which CSS cannot reach on a native
33
+ * select. That is a real limit and the honest reason to build the custom one —
34
+ * when a product needs icons or two-line entries in the options, not before.
35
+ * The closed control is fully styled here, from the same surface the text input
36
+ * uses, so the two match in a form.
37
+ */
38
+
39
+ const PandaSelect = styled("select", inputDropdownRecipe);
40
+
41
+ export interface SelectOption {
42
+ value: string;
43
+ label: string;
44
+ disabled?: boolean;
45
+ }
46
+
47
+ export interface StyledInputSelectProps
48
+ extends Omit<
49
+ React.SelectHTMLAttributes<HTMLSelectElement>,
50
+ "color" | "content" | "translate" | "size"
51
+ >,
52
+ Omit<HTMLStyledProps<"select">, "size"> {
53
+ ["data-testid"]?: string;
54
+ variant?: SelectVariant;
55
+ /**
56
+ * The choices. Alternatively pass `<option>` children directly — needed for
57
+ * `<optgroup>`, which this prop deliberately does not model.
58
+ */
59
+ options?: SelectOption[];
60
+ /**
61
+ * Leading entry for "nothing chosen". Its value is the empty string, so a
62
+ * `required` select rejects it — which is the point of naming it rather than
63
+ * letting the first real option be silently pre-selected.
64
+ */
65
+ placeholder?: string;
66
+ }
67
+
68
+ /** What `inputDropdownRecipe` defines — wider than the five app-wide ones. */
69
+ export const SELECT_VARIANTS = [
70
+ "solid",
71
+ "outline",
72
+ "aurora",
73
+ "glass",
74
+ "matte",
75
+ "ghost",
76
+ "none",
77
+ ] as const;
78
+
79
+ export type SelectVariant = (typeof SELECT_VARIANTS)[number];
80
+
81
+ const StyledInputSelect = React.forwardRef<
82
+ HTMLSelectElement,
83
+ StyledInputSelectProps
84
+ >(function StyledInputSelect(
85
+ { variant, options, placeholder, children, style, ...props },
86
+ ref,
87
+ ) {
88
+ const resolved = useResolvedVariant(variant, SELECT_VARIANTS);
89
+ const fontSize = fontSizeMap[useFontSizeProfile()] ?? fontSizeMap.md;
90
+
91
+ return (
92
+ <PandaSelect
93
+ ref={ref}
94
+ variant={resolved}
95
+ data-testid={props["data-testid"]}
96
+ // Inline, not a Panda prop: the value is only known at runtime, and Panda
97
+ // extracts styles by parsing source at build time — a runtime value
98
+ // yields a class with no rule behind it (NEH-233).
99
+ style={{ fontSize, ...style }}
100
+ {...props}
101
+ >
102
+ {placeholder !== undefined && <option value="">{placeholder}</option>}
103
+ {options?.map((option) => (
104
+ <option key={option.value} value={option.value} disabled={option.disabled}>
105
+ {option.label}
106
+ </option>
107
+ ))}
108
+ {children}
109
+ </PandaSelect>
110
+ );
111
+ });
112
+
113
+ StyledInputSelect.displayName = "StyledInputSelect";
114
+
115
+ export default StyledInputSelect;
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import StyledBox from "./StyledBox";
5
+ import StyledHStack from "./StyledHStack";
6
+ import StyledText from "./StyledText";
7
+
8
+ /**
9
+ * A range slider with optional end labels and a live readout.
10
+ *
11
+ * The end labels are the reason to reach for this over a bare `<input
12
+ * type="range">`: a slider with no anchors is a guess, and "Quiet"/"Loud" at
13
+ * the ends costs nothing and tells the reader what the axis means.
14
+ *
15
+ * ## Two things were fixed on the way in
16
+ *
17
+ * **Fractional steps were being truncated.** The change handler ran
18
+ * `parseInt(value, 10)`, so `step={0.5}` produced 2 where the user had chosen
19
+ * 2.5 — the thumb would snap back as the value round-tripped. Now `Number`,
20
+ * which reads the whole value. Callers on integer steps are unaffected.
21
+ *
22
+ * **The slider had no accessible name.** The visible readout is a separate
23
+ * `<StyledText>`, so a screen reader met an unlabelled "slider, 40". When
24
+ * `currentLabel` is set it now names the control too, which is nearly always
25
+ * what the caller meant; an explicit `aria-label` or `aria-labelledby` wins.
26
+ */
27
+
28
+ export interface StyledInputSliderProps
29
+ extends Omit<
30
+ React.InputHTMLAttributes<HTMLInputElement>,
31
+ "value" | "onChange" | "min" | "max" | "step" | "type"
32
+ > {
33
+ value: number;
34
+ onChange: (value: number) => void;
35
+ min?: number;
36
+ max?: number;
37
+ step?: number;
38
+ /** Text at the low end of the track. */
39
+ minLabel?: string;
40
+ /** Text at the high end. */
41
+ maxLabel?: string;
42
+ /** Names the quantity — rendered as "`currentLabel`: value" beneath. */
43
+ currentLabel?: string;
44
+ }
45
+
46
+ const StyledInputSlider = ({
47
+ value,
48
+ onChange,
49
+ min,
50
+ max,
51
+ step,
52
+ minLabel,
53
+ maxLabel,
54
+ currentLabel,
55
+ ...props
56
+ }: StyledInputSliderProps) => (
57
+ <StyledBox noWrap>
58
+ <StyledHStack justifyContent="space-between" alignItems="center">
59
+ {minLabel && <StyledText>{minLabel}</StyledText>}
60
+ <input
61
+ type="range"
62
+ value={value}
63
+ // `Number`, not `parseInt` — see above.
64
+ onChange={(e) => onChange(Number(e.target.value))}
65
+ min={min}
66
+ max={max}
67
+ step={step}
68
+ aria-label={currentLabel}
69
+ {...props}
70
+ />
71
+ {maxLabel && <StyledText>{maxLabel}</StyledText>}
72
+ </StyledHStack>
73
+ {currentLabel && (
74
+ <StyledText textAlign="center" mt={2}>
75
+ {currentLabel}: {value}
76
+ </StyledText>
77
+ )}
78
+ </StyledBox>
79
+ );
80
+
81
+ StyledInputSlider.displayName = "StyledInputSlider";
82
+
83
+ export default StyledInputSlider;
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+ import { inputTextRecipe } from "styled-system/recipes";
7
+ import { useFontSizeProfile, useResolvedVariant } from "../config/style-config";
8
+ import { fontSizeMap } from "../config/font-size";
9
+ import DictationControls, { dictationPadding } from "./DictationControls";
10
+ import DictationPrompt from "./DictationPrompt";
11
+ import type { Dictation } from "./dictation";
12
+
13
+ /**
14
+ * A single-line text field.
15
+ *
16
+ * Sized from the app-wide font-size profile rather than the browser default,
17
+ * which matters more here than almost anywhere else: a field the user cannot
18
+ * read is a field they cannot check before submitting.
19
+ *
20
+ * ## Dictation is supplied, never implemented
21
+ *
22
+ * Pass a `dictation` adapter and the field grows a microphone; omit it and the
23
+ * field is exactly a field. This package holds no speech code — see
24
+ * `dictation.ts` for why that seam is where it is.
25
+ *
26
+ * The originating component decided for itself whether a mic belonged, from a
27
+ * feature flag, the input's `type`, and a "context" that also chose between a
28
+ * browser engine and AWS Transcribe for PHI. All of that is product policy with
29
+ * regulatory weight, and none of it survived the move — the host decides, and
30
+ * says so by passing an adapter or not.
31
+ *
32
+ * That inverts one behaviour worth naming: the old component auto-enabled a mic
33
+ * for any user with the feature flag, so opting a field OUT meant remembering
34
+ * `showMic={false}`. A field that should never be dictated into — a PIN, a card
35
+ * number — was one forgotten prop away from having a microphone. Now silence is
36
+ * the default and dictation is the deliberate act.
37
+ */
38
+
39
+ const PandaInput = styled("input", inputTextRecipe);
40
+
41
+ export interface StyledInputTextProps
42
+ extends Omit<
43
+ React.InputHTMLAttributes<HTMLInputElement>,
44
+ "color" | "content" | "height" | "translate" | "width" | "size"
45
+ >,
46
+ Omit<HTMLStyledProps<"input">, "size"> {
47
+ ["data-testid"]?: string;
48
+ variant?: InputTextVariant;
49
+ isReadOnly?: boolean;
50
+ size?: string | number;
51
+ /**
52
+ * Host-supplied dictation. Omit for no microphone — which is most fields.
53
+ */
54
+ dictation?: Dictation;
55
+ /** Accessible name for the mic button. */
56
+ micLabel?: string;
57
+ /** Accessible name for the redo button. */
58
+ redoLabel?: string;
59
+ /** Wording for the "add or replace?" prompt a second recording raises. */
60
+ continueQuestion?: string;
61
+ continueLabel?: string;
62
+ startOverLabel?: string;
63
+ }
64
+
65
+ /**
66
+ * What `inputTextRecipe` defines. Wider than the five appearances selectable
67
+ * app-wide, so it is passed to `useResolvedVariant` explicitly — otherwise
68
+ * `ghost` and `none` are silently coerced to `solid`.
69
+ */
70
+ export const INPUT_TEXT_VARIANTS = [
71
+ "solid",
72
+ "outline",
73
+ "aurora",
74
+ "glass",
75
+ "matte",
76
+ "ghost",
77
+ "none",
78
+ ] as const;
79
+
80
+ export type InputTextVariant = (typeof INPUT_TEXT_VARIANTS)[number];
81
+
82
+ const StyledInputText = React.forwardRef<HTMLInputElement, StyledInputTextProps>(
83
+ function StyledInputText(
84
+ {
85
+ variant,
86
+ isReadOnly,
87
+ size: _size,
88
+ style,
89
+ dictation,
90
+ micLabel = "Dictate",
91
+ redoLabel = "Record again",
92
+ continueQuestion = "Add to what you already wrote?",
93
+ continueLabel = "Continue",
94
+ startOverLabel = "Start over",
95
+ ...props
96
+ },
97
+ ref,
98
+ ) {
99
+ const resolved = useResolvedVariant(variant, INPUT_TEXT_VARIANTS);
100
+ const fontSize = fontSizeMap[useFontSizeProfile()] ?? fontSizeMap.md;
101
+ const padding = dictationPadding(dictation);
102
+
103
+ const field = (
104
+ <PandaInput
105
+ ref={ref}
106
+ variant={resolved}
107
+ data-testid={props["data-testid"]}
108
+ readOnly={isReadOnly}
109
+ style={{
110
+ fontSize,
111
+ // Reserve room so the value does not run underneath the buttons.
112
+ ...(padding ? { paddingRight: padding } : {}),
113
+ ...style,
114
+ }}
115
+ {...props}
116
+ />
117
+ );
118
+
119
+ if (!dictation) return field;
120
+
121
+ return (
122
+ <>
123
+ <div style={{ position: "relative", display: "inline-block", width: "100%" }}>
124
+ {field}
125
+ <DictationControls
126
+ dictation={dictation}
127
+ micLabel={micLabel}
128
+ redoLabel={redoLabel}
129
+ />
130
+ </div>
131
+ {/* Below the field, outside the positioned wrapper — it is a question,
132
+ not an affordance. */}
133
+ <DictationPrompt
134
+ dictation={dictation}
135
+ question={continueQuestion}
136
+ continueLabel={continueLabel}
137
+ startOverLabel={startOverLabel}
138
+ />
139
+ </>
140
+ );
141
+ },
142
+ );
143
+
144
+ StyledInputText.displayName = "StyledInputText";
145
+
146
+ export default StyledInputText;
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+ import { inputTextRecipe } from "styled-system/recipes";
7
+ import { useFontSizeProfile, useResolvedVariant } from "../config/style-config";
8
+ import { fontSizeMap } from "../config/font-size";
9
+ import DictationControls, { dictationPadding } from "./DictationControls";
10
+ import DictationPrompt from "./DictationPrompt";
11
+ import { INPUT_TEXT_VARIANTS, type InputTextVariant } from "./StyledInputText";
12
+ import type { Dictation } from "./dictation";
13
+
14
+ /**
15
+ * A multi-line text field. `StyledInputText`'s sibling, sharing its recipe and
16
+ * its dictation seam — read that file first; the reasoning is the same.
17
+ *
18
+ * The one deliberate difference is where the buttons sit. On a single-line
19
+ * field they centre vertically, because there is only one line to centre
20
+ * against. Here they pin to the **top**: a textarea grows, and a
21
+ * vertically-centred button would drift down the field as the user typed,
22
+ * ending up beside the middle of their text with no relationship to anything.
23
+ *
24
+ * Dictation matters more here than on the single-line field. Long-form entry is
25
+ * where typing is most tiring, so this is the control an arthritic or tremoring
26
+ * user is most likely to want — which is also why the buttons keep their full
27
+ * 44×44 target rather than shrinking to stay out of the way.
28
+ */
29
+
30
+ const PandaTextArea = styled("textarea", inputTextRecipe);
31
+
32
+ export interface StyledInputTextAreaProps
33
+ extends Omit<
34
+ React.TextareaHTMLAttributes<HTMLTextAreaElement>,
35
+ "color" | "content" | "translate"
36
+ >,
37
+ HTMLStyledProps<"textarea"> {
38
+ ["data-testid"]?: string;
39
+ variant?: InputTextVariant;
40
+ isReadOnly?: boolean;
41
+ /** Host-supplied dictation. Omit for no microphone. */
42
+ dictation?: Dictation;
43
+ micLabel?: string;
44
+ redoLabel?: string;
45
+ /** Wording for the "add or replace?" prompt a second recording raises. */
46
+ continueQuestion?: string;
47
+ continueLabel?: string;
48
+ startOverLabel?: string;
49
+ }
50
+
51
+ const StyledInputTextArea = React.forwardRef<
52
+ HTMLTextAreaElement,
53
+ StyledInputTextAreaProps
54
+ >(function StyledInputTextArea(
55
+ {
56
+ variant,
57
+ isReadOnly,
58
+ style,
59
+ dictation,
60
+ micLabel = "Dictate",
61
+ redoLabel = "Record again",
62
+ continueQuestion = "Add to what you already wrote?",
63
+ continueLabel = "Continue",
64
+ startOverLabel = "Start over",
65
+ ...props
66
+ },
67
+ ref,
68
+ ) {
69
+ const resolved = useResolvedVariant(variant, INPUT_TEXT_VARIANTS);
70
+ const fontSize = fontSizeMap[useFontSizeProfile()] ?? fontSizeMap.md;
71
+ const padding = dictationPadding(dictation);
72
+
73
+ const field = (
74
+ <PandaTextArea
75
+ ref={ref}
76
+ variant={resolved}
77
+ data-testid={props["data-testid"]}
78
+ readOnly={isReadOnly}
79
+ style={{
80
+ fontSize,
81
+ ...(padding ? { paddingRight: padding } : {}),
82
+ ...style,
83
+ }}
84
+ {...props}
85
+ />
86
+ );
87
+
88
+ if (!dictation) return field;
89
+
90
+ return (
91
+ <>
92
+ <div
93
+ style={{ position: "relative", display: "inline-block", width: "100%" }}
94
+ // Pins the controls to the top of a growing field — see above.
95
+ data-dictation-anchor="top"
96
+ >
97
+ {field}
98
+ <DictationControls
99
+ dictation={dictation}
100
+ micLabel={micLabel}
101
+ redoLabel={redoLabel}
102
+ anchor="top"
103
+ />
104
+ </div>
105
+ {/* This is where the prompt matters most: a textarea is where someone has
106
+ actually written a paragraph worth losing. */}
107
+ <DictationPrompt
108
+ dictation={dictation}
109
+ question={continueQuestion}
110
+ continueLabel={continueLabel}
111
+ startOverLabel={startOverLabel}
112
+ />
113
+ </>
114
+ );
115
+ });
116
+
117
+ StyledInputTextArea.displayName = "StyledInputTextArea";
118
+
119
+ export default StyledInputTextArea;