@estiva-app/ui 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.3.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",
@@ -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 { ReactNode } from 'react'
1
+ import { createContext, useContext, useId, type ReactNode } from 'react'
2
2
 
3
3
  /**
4
- * Peek's Field (2026-08-28), verbatim: a label over a control, 8px apart,
5
- * with a red asterisk when required. The label is the `input-label` type
6
- * token as a plain class, never merged.
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 className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}>
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'