@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.
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +699 -0
- package/package.json +95 -0
- package/src/components/DictationControls.tsx +141 -0
- package/src/components/DictationPrompt.tsx +78 -0
- package/src/components/StyledBox.tsx +174 -0
- package/src/components/StyledButton.tsx +144 -0
- package/src/components/StyledCollapsible.tsx +127 -0
- package/src/components/StyledDefinitionList.tsx +134 -0
- package/src/components/StyledFieldset.tsx +157 -0
- package/src/components/StyledFlex.tsx +13 -0
- package/src/components/StyledFooter.tsx +399 -0
- package/src/components/StyledFormLabel.tsx +141 -0
- package/src/components/StyledGrid.tsx +109 -0
- package/src/components/StyledGridItem.tsx +19 -0
- package/src/components/StyledHStack.tsx +145 -0
- package/src/components/StyledHeading.tsx +79 -0
- package/src/components/StyledHrRule.tsx +33 -0
- package/src/components/StyledIcon.tsx +172 -0
- package/src/components/StyledIconButton.tsx +135 -0
- package/src/components/StyledInputBool.tsx +81 -0
- package/src/components/StyledInputRadio.tsx +141 -0
- package/src/components/StyledInputSelect.tsx +115 -0
- package/src/components/StyledInputSlider.tsx +83 -0
- package/src/components/StyledInputText.tsx +146 -0
- package/src/components/StyledInputTextArea.tsx +119 -0
- package/src/components/StyledInputToggle.tsx +224 -0
- package/src/components/StyledList.tsx +188 -0
- package/src/components/StyledScrollbar.tsx +53 -0
- package/src/components/StyledSearch.tsx +78 -0
- package/src/components/StyledSeparator.tsx +38 -0
- package/src/components/StyledSidebar.tsx +555 -0
- package/src/components/StyledSimpleGrid.tsx +99 -0
- package/src/components/StyledSparkLine.tsx +119 -0
- package/src/components/StyledSpinner.tsx +91 -0
- package/src/components/StyledStack.tsx +62 -0
- package/src/components/StyledText.tsx +99 -0
- package/src/components/StyledTooltip.tsx +398 -0
- package/src/components/StyledVStack.tsx +143 -0
- package/src/components/TitleLogo.tsx +223 -0
- package/src/components/create-icon.tsx +66 -0
- package/src/components/create-intent-button.tsx +134 -0
- package/src/components/dictation.ts +71 -0
- package/src/components/intent-buttons.ts +154 -0
- package/src/config/can-hover.ts +75 -0
- package/src/config/density.ts +138 -0
- package/src/config/font-size.ts +113 -0
- package/src/config/intent-icons.tsx +116 -0
- package/src/config/logger.ts +60 -0
- package/src/config/style-config.tsx +263 -0
- package/src/config/types.ts +137 -0
- package/src/index.ts +259 -0
- package/src/preset/index.ts +243 -0
- package/src/preset/recipes/arrows.ts +29 -0
- package/src/preset/recipes/box.ts +122 -0
- package/src/preset/recipes/button.ts +161 -0
- package/src/preset/recipes/dl-list.ts +109 -0
- package/src/preset/recipes/drawer.ts +125 -0
- package/src/preset/recipes/form.ts +95 -0
- package/src/preset/recipes/icon-button.ts +161 -0
- package/src/preset/recipes/icon.ts +34 -0
- package/src/preset/recipes/input-bool.ts +184 -0
- package/src/preset/recipes/input-dropdown.ts +93 -0
- package/src/preset/recipes/input-radio.ts +158 -0
- package/src/preset/recipes/input-surface.ts +152 -0
- package/src/preset/recipes/input-text.ts +17 -0
- package/src/preset/recipes/list.ts +196 -0
- package/src/preset/recipes/menu.ts +28 -0
- package/src/preset/recipes/separator.ts +89 -0
- package/src/preset/recipes/stack.ts +89 -0
- package/src/preset/recipes/striped.ts +34 -0
- package/src/preset/recipes/text.ts +41 -0
- package/src/preset/recipes/tooltip.ts +77 -0
- 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;
|