@estiva-app/ui 0.2.0 → 0.4.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/dist/EditableText.d.ts +21 -1
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +32 -5
- package/dist/Field.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -18
- package/dist/index.js.map +3 -3
- package/package.json +5 -1
- package/src/EditableText.tsx +25 -6
- package/src/Field.test.tsx +117 -0
- package/src/Field.tsx +61 -7
- package/src/TextInput.tsx +5 -1
- package/src/Textarea.tsx +5 -1
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,12 +42,16 @@
|
|
|
42
42
|
"@storybook/addon-docs": "^10.5.10",
|
|
43
43
|
"@storybook/react-vite": "^10.5.10",
|
|
44
44
|
"@tabler/icons-react": "^3.41.1",
|
|
45
|
+
"@testing-library/dom": "^10.4.1",
|
|
46
|
+
"@testing-library/react": "^16.3.3",
|
|
47
|
+
"@testing-library/user-event": "^14.6.7",
|
|
45
48
|
"@types/node": "^24.12.0",
|
|
46
49
|
"@types/react": "^19.2.14",
|
|
47
50
|
"@types/react-dom": "^19.2.3",
|
|
48
51
|
"@vitejs/plugin-react": "^6.0.1",
|
|
49
52
|
"autoprefixer": "^10.4.27",
|
|
50
53
|
"esbuild": "^0.28.0",
|
|
54
|
+
"jsdom": "^30.0.1",
|
|
51
55
|
"postcss": "^8.5.8",
|
|
52
56
|
"react": "^19.2.4",
|
|
53
57
|
"react-dom": "^19.2.4",
|
package/src/EditableText.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, type KeyboardEvent, type RefObject } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -32,11 +32,30 @@ export interface EditableTextProps {
|
|
|
32
32
|
* — which is the failure a naive `value={stripped}` would cause.
|
|
33
33
|
*/
|
|
34
34
|
display?: string
|
|
35
|
+
/**
|
|
36
|
+
* What to *draw* when not editing, when the value is structured rather than
|
|
37
|
+
* a line of prose.
|
|
38
|
+
*
|
|
39
|
+
* A node, not a string, because a heading and a bullet are elements. Added
|
|
40
|
+
* for a rich text field (SPEC §13): the app renders the parsed body and
|
|
41
|
+
* hands the result in.
|
|
42
|
+
*
|
|
43
|
+
* `display` stays the **string**, and stays what decides emptiness — so a
|
|
44
|
+
* blank field still shows its placeholder rather than an empty element. Like
|
|
45
|
+
* `display`, this overrides the display branch only: `value` is what the
|
|
46
|
+
* editor opens with and what a commit compares against, so what is edited is
|
|
47
|
+
* unchanged.
|
|
48
|
+
*
|
|
49
|
+
* `whitespace-pre-wrap` is dropped when this is set. Structured content
|
|
50
|
+
* carries its own line breaks, and preserving the source's as well doubles
|
|
51
|
+
* every one of them.
|
|
52
|
+
*/
|
|
53
|
+
displayNode?: ReactNode
|
|
35
54
|
/** Show the value only — no edit affordance. For a reader who cannot write. */
|
|
36
55
|
readOnly?: boolean
|
|
37
56
|
}
|
|
38
57
|
|
|
39
|
-
export function EditableText({ value, display, placeholder, onCommit, multiline = false, className, label, readOnly = false }: EditableTextProps) {
|
|
58
|
+
export function EditableText({ value, display, displayNode, placeholder, onCommit, multiline = false, className, label, readOnly = false }: EditableTextProps) {
|
|
40
59
|
const [editing, setEditing] = useState(false)
|
|
41
60
|
const [draft, setDraft] = useState(value)
|
|
42
61
|
const [busy, setBusy] = useState(false)
|
|
@@ -97,9 +116,9 @@ export function EditableText({ value, display, placeholder, onCommit, multiline
|
|
|
97
116
|
return (
|
|
98
117
|
<div
|
|
99
118
|
aria-label={label}
|
|
100
|
-
className={cn('w-full px-2 py-1', multiline && 'whitespace-pre-wrap', (display ?? value) ? 'text-text-primary' : 'text-text-muted', className)}
|
|
119
|
+
className={cn('w-full px-2 py-1', multiline && !displayNode && 'whitespace-pre-wrap', (display ?? value) ? 'text-text-primary' : 'text-text-muted', className)}
|
|
101
120
|
>
|
|
102
|
-
{(display ?? value)
|
|
121
|
+
{(display ?? value) ? (displayNode ?? (display ?? value)) : placeholder}
|
|
103
122
|
</div>
|
|
104
123
|
)
|
|
105
124
|
}
|
|
@@ -139,12 +158,12 @@ export function EditableText({ value, display, placeholder, onCommit, multiline
|
|
|
139
158
|
aria-label={`Edit ${label.toLowerCase()}`}
|
|
140
159
|
className={cn(
|
|
141
160
|
'w-full rounded-md border border-transparent px-2 py-1 text-left transition-colors hover:border-border-default',
|
|
142
|
-
multiline && 'whitespace-pre-wrap',
|
|
161
|
+
multiline && !displayNode && 'whitespace-pre-wrap',
|
|
143
162
|
(display ?? value) ? 'text-text-primary' : 'text-text-muted',
|
|
144
163
|
className,
|
|
145
164
|
)}
|
|
146
165
|
>
|
|
147
|
-
{(display ?? value)
|
|
166
|
+
{(display ?? value) ? (displayNode ?? (display ?? value)) : placeholder}
|
|
148
167
|
</button>
|
|
149
168
|
)
|
|
150
169
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The label names its control — SHA-17.
|
|
4
|
+
*
|
|
5
|
+
* It did not. `Field` rendered a `<label>` with no `htmlFor` and the control as
|
|
6
|
+
* its *sibling*, so there was neither an explicit nor an implicit association:
|
|
7
|
+
* a screen reader announced an unlabelled edit box, and clicking the label
|
|
8
|
+
* focused nothing.
|
|
9
|
+
*
|
|
10
|
+
* These are the first DOM tests in this package, and they exist because the
|
|
11
|
+
* defect cannot be seen without a document. It surfaced in a *consumer's* test
|
|
12
|
+
* — `getByLabelText(/title/i)` in Peek's ActionFormPanel suite, failing with
|
|
13
|
+
* "Found a label with the text of: /title/i, however no form control was found
|
|
14
|
+
* associated to that label". That message is the bug stated precisely, and the
|
|
15
|
+
* consumer worked around it by querying by role. The guard belongs here, where
|
|
16
|
+
* the next primitive added under Field will meet it.
|
|
17
|
+
*/
|
|
18
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
19
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
20
|
+
import { Field } from './Field'
|
|
21
|
+
import { TextInput } from './TextInput'
|
|
22
|
+
import { Textarea } from './Textarea'
|
|
23
|
+
|
|
24
|
+
// Testing Library registers its own cleanup only when vitest runs with
|
|
25
|
+
// `globals: true`, and this package does not — so the first two tests passed,
|
|
26
|
+
// the third found two elements labelled "Title", and the failure read like a
|
|
27
|
+
// bug in the component.
|
|
28
|
+
afterEach(cleanup)
|
|
29
|
+
|
|
30
|
+
describe('Field', () => {
|
|
31
|
+
it('names a TextInput, so it is reachable by its label', () => {
|
|
32
|
+
render(
|
|
33
|
+
<Field label="Title">
|
|
34
|
+
<TextInput defaultValue="" />
|
|
35
|
+
</Field>,
|
|
36
|
+
)
|
|
37
|
+
expect(screen.getByLabelText('Title').tagName).toBe('INPUT')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('names a Textarea too', () => {
|
|
41
|
+
render(
|
|
42
|
+
<Field label="Description">
|
|
43
|
+
<Textarea defaultValue="" />
|
|
44
|
+
</Field>,
|
|
45
|
+
)
|
|
46
|
+
expect(screen.getByLabelText('Description').tagName).toBe('TEXTAREA')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('points the label at the control it wraps, not at some other field', () => {
|
|
50
|
+
// Two Fields on one form is the case a single hardcoded id gets wrong, and
|
|
51
|
+
// `useId` is what makes it right. Asserted because "it works with one
|
|
52
|
+
// field" is the version of this that ships broken.
|
|
53
|
+
const { container } = render(
|
|
54
|
+
<form>
|
|
55
|
+
<Field label="Title">
|
|
56
|
+
<TextInput defaultValue="" />
|
|
57
|
+
</Field>
|
|
58
|
+
<Field label="Description">
|
|
59
|
+
<Textarea defaultValue="" />
|
|
60
|
+
</Field>
|
|
61
|
+
</form>,
|
|
62
|
+
)
|
|
63
|
+
const labels = [...container.querySelectorAll('label')]
|
|
64
|
+
const ids = labels.map((l) => l.getAttribute('for'))
|
|
65
|
+
expect(ids.filter(Boolean)).toHaveLength(2)
|
|
66
|
+
expect(new Set(ids).size).toBe(2)
|
|
67
|
+
expect(screen.getByLabelText('Title')).toBe(container.querySelector(`#${CSS.escape(ids[0]!)}`))
|
|
68
|
+
expect(screen.getByLabelText('Description')).toBe(container.querySelector(`#${CSS.escape(ids[1]!)}`))
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('wins over an id the caller put on the control, rather than breaking the pair', () => {
|
|
72
|
+
// The first version of this asserted the opposite — that a control keeps
|
|
73
|
+
// its own id inside a Field — and it failed with this ticket's own error
|
|
74
|
+
// message, because the label went on pointing at the generated id. Two
|
|
75
|
+
// halves of one association cannot be set from two places.
|
|
76
|
+
//
|
|
77
|
+
// So the Field wins, and `htmlFor` below is how a caller chooses. Silently
|
|
78
|
+
// overriding an id is a smaller surprise than silently unlabelling a
|
|
79
|
+
// control, and only one of the two is invisible until somebody uses a
|
|
80
|
+
// screen reader.
|
|
81
|
+
render(
|
|
82
|
+
<Field label="Title">
|
|
83
|
+
<TextInput id="chosen-by-the-caller" defaultValue="" />
|
|
84
|
+
</Field>,
|
|
85
|
+
)
|
|
86
|
+
const input = screen.getByLabelText('Title')
|
|
87
|
+
expect(input.tagName).toBe('INPUT')
|
|
88
|
+
expect(input.id).not.toBe('chosen-by-the-caller')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('lets the Field be told the id instead, for the same reason', () => {
|
|
92
|
+
render(
|
|
93
|
+
<Field label="Title" htmlFor="named-outside">
|
|
94
|
+
<TextInput defaultValue="" />
|
|
95
|
+
</Field>,
|
|
96
|
+
)
|
|
97
|
+
expect(screen.getByLabelText('Title').id).toBe('named-outside')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves a control outside a Field alone', () => {
|
|
101
|
+
// `useFieldControlId` returns undefined outside a provider, so nothing
|
|
102
|
+
// acquires a stray id — a control with an id it did not ask for is its own
|
|
103
|
+
// small bug.
|
|
104
|
+
const { container } = render(<TextInput defaultValue="" />)
|
|
105
|
+
expect(container.querySelector('input')?.getAttribute('id')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('still marks a required field, which was the only thing it did before', () => {
|
|
109
|
+
render(
|
|
110
|
+
<Field label="Title" required>
|
|
111
|
+
<TextInput defaultValue="" />
|
|
112
|
+
</Field>,
|
|
113
|
+
)
|
|
114
|
+
expect(screen.getByText('*')).toBeTruthy()
|
|
115
|
+
expect(screen.getByLabelText(/Title/).tagName).toBe('INPUT')
|
|
116
|
+
})
|
|
117
|
+
})
|
package/src/Field.tsx
CHANGED
|
@@ -1,24 +1,78 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createContext, useContext, useId, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The id of the control this Field labels.
|
|
5
|
+
*
|
|
6
|
+
* **A control has to opt in by calling `useFieldControlId`.** The automatic
|
|
7
|
+
* alternative is nesting the control inside the `<label>`, which associates
|
|
8
|
+
* anything by construction and needs no cooperation — and it is not used here,
|
|
9
|
+
* because a control that is *both* nested in a label and named by its `htmlFor`
|
|
10
|
+
* can receive two activations from one click. That is a real hazard for a
|
|
11
|
+
* checkbox and a latent one for everything else, and this library has a
|
|
12
|
+
* `Checkbox`.
|
|
13
|
+
*
|
|
14
|
+
* So: one explicit mechanism, and a test that pins it for every primitive that
|
|
15
|
+
* uses it (`Field.test.tsx`). A new primitive that renders a labelable element
|
|
16
|
+
* calls this hook and spreads the result; one that does not is unlabelled, and
|
|
17
|
+
* the test is where that gets noticed.
|
|
18
|
+
*/
|
|
19
|
+
const FieldControlIdContext = createContext<string | undefined>(undefined)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The id a surrounding `Field` wants this control to have, falling back to the
|
|
23
|
+
* caller's own outside one.
|
|
24
|
+
*
|
|
25
|
+
* **Inside a Field, the Field wins**, which is the opposite of what I wrote
|
|
26
|
+
* first and the test caught within the minute. Letting a control's own `id`
|
|
27
|
+
* take precedence leaves the label's `htmlFor` pointing at the id the Field
|
|
28
|
+
* generated and the control answering to a different one — which is this
|
|
29
|
+
* ticket's defect exactly, reproduced by the fix for it, and it fails with the
|
|
30
|
+
* same message: *"Found a label with the text of: Title, however no form
|
|
31
|
+
* control was found associated to that label."*
|
|
32
|
+
*
|
|
33
|
+
* A caller who needs to choose the id names it on the Field (`htmlFor`), which
|
|
34
|
+
* is the one place that can set both halves. Outside a Field there is nothing
|
|
35
|
+
* to disagree with, so the caller's id is used.
|
|
36
|
+
*/
|
|
37
|
+
export function useFieldControlId(ownId?: string): string | undefined {
|
|
38
|
+
const fromField = useContext(FieldControlIdContext)
|
|
39
|
+
return fromField ?? ownId
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Peek's Field (2026-08-28): a label over a control, 8px apart, with a red
|
|
44
|
+
* asterisk when required. The label is the `input-label` type token as a plain
|
|
45
|
+
* class, never merged.
|
|
46
|
+
*
|
|
47
|
+
* The label names its control (SHA-17). It did not, and the control was a
|
|
48
|
+
* *sibling* of the label with no `htmlFor`, so there was neither an explicit
|
|
49
|
+
* nor an implicit association: a screen reader announced an unlabelled edit
|
|
50
|
+
* box and clicking the label focused nothing.
|
|
7
51
|
*/
|
|
8
52
|
export interface FieldProps {
|
|
9
53
|
label: string
|
|
10
54
|
required?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Override the generated id. Only needed when something outside has to name
|
|
57
|
+
* the control — an `aria-describedby` elsewhere, or a form library.
|
|
58
|
+
*/
|
|
59
|
+
htmlFor?: string
|
|
11
60
|
children: ReactNode
|
|
12
61
|
}
|
|
13
62
|
|
|
14
|
-
export function Field({ label, required = false, children }: FieldProps) {
|
|
63
|
+
export function Field({ label, required = false, htmlFor, children }: FieldProps) {
|
|
64
|
+
const generated = useId()
|
|
65
|
+
const id = htmlFor ?? generated
|
|
15
66
|
return (
|
|
16
67
|
<div className="flex flex-col gap-2">
|
|
17
|
-
<label
|
|
68
|
+
<label
|
|
69
|
+
htmlFor={id}
|
|
70
|
+
className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}
|
|
71
|
+
>
|
|
18
72
|
{label}
|
|
19
73
|
{required && <span className="text-error-default ml-0.5">*</span>}
|
|
20
74
|
</label>
|
|
21
|
-
{children}
|
|
75
|
+
<FieldControlIdContext.Provider value={id}>{children}</FieldControlIdContext.Provider>
|
|
22
76
|
</div>
|
|
23
77
|
)
|
|
24
78
|
}
|
package/src/TextInput.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { useFieldControlId } from './Field'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
|
|
@@ -10,10 +11,13 @@ import { cn } from './cn'
|
|
|
10
11
|
*/
|
|
11
12
|
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>
|
|
12
13
|
|
|
13
|
-
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
|
|
14
|
+
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', id, ...props }, ref) {
|
|
15
|
+
// A surrounding Field names this control (SHA-17); an explicit id still wins.
|
|
16
|
+
const controlId = useFieldControlId(id)
|
|
14
17
|
return (
|
|
15
18
|
<input
|
|
16
19
|
ref={ref}
|
|
20
|
+
id={controlId}
|
|
17
21
|
type={type}
|
|
18
22
|
className={cn(
|
|
19
23
|
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
package/src/Textarea.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { useFieldControlId } from './Field'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
|
|
@@ -7,10 +8,13 @@ import { cn } from './cn'
|
|
|
7
8
|
*/
|
|
8
9
|
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
|
|
9
10
|
|
|
10
|
-
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, ...props }, ref) {
|
|
11
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, id, ...props }, ref) {
|
|
12
|
+
// A surrounding Field names this control (SHA-17); an explicit id still wins.
|
|
13
|
+
const controlId = useFieldControlId(id)
|
|
11
14
|
return (
|
|
12
15
|
<textarea
|
|
13
16
|
ref={ref}
|
|
17
|
+
id={controlId}
|
|
14
18
|
className={cn(
|
|
15
19
|
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
|
16
20
|
'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type I
|
|
|
20
20
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
21
21
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
22
22
|
export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
|
|
23
|
-
export { Field, type FieldProps } from './Field'
|
|
23
|
+
export { Field, useFieldControlId, type FieldProps } from './Field'
|
|
24
24
|
export { Select, type SelectOption, type SelectProps } from './Select'
|
|
25
25
|
export { TextInput, type TextInputProps } from './TextInput'
|
|
26
26
|
export { Textarea, type TextareaProps } from './Textarea'
|