@eifi1/ui-kit 0.5.0 → 0.6.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 (144) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +22 -7
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The react-hook-form adapter: the five parts every form is built from, and the hook
3
+ * they share. `@eifi1/ui-kit/rhf`.
4
+ *
5
+ * ```tsx
6
+ * <Form {...form}>
7
+ * <FormField control={form.control} name="amount" render={({ field }) => (
8
+ * <FormItem>
9
+ * <FormLabel required>Amount</FormLabel>
10
+ * <FormControl>
11
+ * <NumberField value={field.value} onCommit={field.onChange} />
12
+ * </FormControl>
13
+ * <FormMessage />
14
+ * </FormItem>
15
+ * )} />
16
+ * </Form>
17
+ * ```
18
+ *
19
+ * The shape is shadcn's `form.tsx`, on purpose: Kastlan builds every form from that
20
+ * file, so moving onto this one is an import line rather than a rewrite. What differs
21
+ * is what the shadcn file gets wrong:
22
+ *
23
+ * - **`aria-describedby` names only what is on screen.** shadcn always points the
24
+ * control at a description id, rendered or not, and at the message id whenever the
25
+ * field has an error, whether a message was rendered for it or not. A reference to
26
+ * a node that does not exist describes the field as nothing. Here the description
27
+ * and the message say when they are mounted, and the control points at exactly
28
+ * those.
29
+ * - **Merged, never replaced.** A control that already carries its own
30
+ * `aria-describedby` keeps it, first — the same rule as the kit's `error` prop
31
+ * (see `useFieldError` in components/ui.tsx).
32
+ * - **No Radix.** `FormControl` is a one-child `cloneElement`, not `Slot`, and the
33
+ * label is the kit's own `Label` — the kit carries no Radix dependency and this
34
+ * entry is not the place to start.
35
+ *
36
+ * ## Why this is its own entry
37
+ *
38
+ * The main barrel once shipped a react-hook-form hook (`use-rhf-wizard-step`) and
39
+ * removed it: it made react-hook-form an install every consumer's typecheck paid for,
40
+ * for a hook nobody called (see the wizard section of src/index.ts). This is the
41
+ * answer to that, not a reversal of it. Nothing outside `src/rhf*` imports
42
+ * react-hook-form — `__tests__/packaging-contract.test.ts` holds that — and the peer is
43
+ * optional, so an app that does not use it installs nothing and imports nothing.
44
+ *
45
+ * ## Painting the field
46
+ *
47
+ * `FormControl` sets `aria-invalid` on its child. `NumberField` and the pickers paint
48
+ * from that attribute; `Input`, `Select` and `Textarea` paint only from their
49
+ * `invalid` prop (see its note in components/ui.tsx: a bare attribute announces but
50
+ * does not paint), so pass it from the render prop —
51
+ * `<Input {...field} invalid={fieldState.invalid} />`. `FormControl` does not inject
52
+ * `invalid` itself because its child may be a native element, which would print it
53
+ * into the DOM as an unknown attribute.
54
+ */
55
+ import {
56
+ Children,
57
+ cloneElement,
58
+ createContext,
59
+ isValidElement,
60
+ useCallback,
61
+ useContext,
62
+ useId,
63
+ useLayoutEffect,
64
+ useMemo,
65
+ useState,
66
+ type ComponentProps,
67
+ type ReactElement,
68
+ } from "react";
69
+ import {
70
+ Controller,
71
+ FormProvider,
72
+ useFormContext,
73
+ useFormState,
74
+ type ControllerProps,
75
+ type FieldPath,
76
+ type FieldValues,
77
+ } from "react-hook-form";
78
+ import { Label, type LabelProps } from "../components/ui";
79
+ import { cn } from "../lib/cn";
80
+
81
+ /** The form's `useForm()` return, spread in: `<Form {...form}>`. react-hook-form's own
82
+ * `FormProvider`, under the name the shadcn file gave it. */
83
+ export const Form = FormProvider;
84
+
85
+ // ── contexts ─────────────────────────────────────────────────────────────────
86
+
87
+ const FormFieldContext = createContext<{ name: string } | null>(null);
88
+
89
+ /** The two optional nodes a control can be described by. */
90
+ type Described = "description" | "message";
91
+
92
+ interface FormItemContextValue {
93
+ id: string;
94
+ mounted: Record<Described, boolean>;
95
+ mark: (slot: Described, on: boolean) => void;
96
+ }
97
+
98
+ const FormItemContext = createContext<FormItemContextValue | null>(null);
99
+
100
+ // ── FormField ────────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * react-hook-form's `Controller`, with the field's name put in context for the parts
104
+ * below it. Takes exactly `Controller`'s props.
105
+ */
106
+ export function FormField<
107
+ TFieldValues extends FieldValues = FieldValues,
108
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
109
+ TTransformedValues = TFieldValues,
110
+ >(props: ControllerProps<TFieldValues, TName, TTransformedValues>) {
111
+ const value = useMemo(() => ({ name: props.name as string }), [props.name]);
112
+ return (
113
+ <FormFieldContext.Provider value={value}>
114
+ <Controller {...props} />
115
+ </FormFieldContext.Provider>
116
+ );
117
+ }
118
+
119
+ // ── useFormField ─────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * The ids and the state of the field around the caller: for a part of your own that
123
+ * sits beside the kit's (a character counter that belongs in `aria-describedby`, a
124
+ * label with a different look).
125
+ *
126
+ * Throws outside a `FormField` + `FormItem` pair. shadcn's version answered there with
127
+ * ids like `undefined-form-item`, which label nothing and fail nowhere.
128
+ */
129
+ export function useFormField() {
130
+ const field = useContext(FormFieldContext);
131
+ const item = useContext(FormItemContext);
132
+ if (!field) throw new Error("useFormField must be used inside <FormField>.");
133
+ if (!item) throw new Error("useFormField must be used inside <FormItem>.");
134
+ const { getFieldState } = useFormContext();
135
+ // Subscribes this part to the field's state, so a label or message re-renders when
136
+ // the field's error changes without the whole form doing so.
137
+ const formState = useFormState({ name: field.name });
138
+ const state = getFieldState(field.name, formState);
139
+ const formDescriptionId = `${item.id}-description`;
140
+ const formMessageId = `${item.id}-message`;
141
+ const describedBy = [
142
+ item.mounted.description && formDescriptionId,
143
+ item.mounted.message && formMessageId,
144
+ ]
145
+ .filter(Boolean)
146
+ .join(" ");
147
+ return {
148
+ id: item.id,
149
+ name: field.name,
150
+ formItemId: `${item.id}-control`,
151
+ formDescriptionId,
152
+ formMessageId,
153
+ /** The ids of the description and message that are actually rendered, or
154
+ * `undefined` when neither is. */
155
+ describedBy: describedBy || undefined,
156
+ ...state,
157
+ };
158
+ }
159
+
160
+ // ── FormItem ─────────────────────────────────────────────────────────────────
161
+
162
+ export type FormItemProps = ComponentProps<"div">;
163
+
164
+ /** One field's box: mints the id its label, control, description and message share. */
165
+ export function FormItem({ className, ...rest }: FormItemProps) {
166
+ const id = useId();
167
+ const [mounted, setMounted] = useState<Record<Described, boolean>>({
168
+ description: false,
169
+ message: false,
170
+ });
171
+ const mark = useCallback(
172
+ (slot: Described, on: boolean) =>
173
+ setMounted((prev) => (prev[slot] === on ? prev : { ...prev, [slot]: on })),
174
+ [],
175
+ );
176
+ const value = useMemo(() => ({ id, mounted, mark }), [id, mounted, mark]);
177
+ return (
178
+ <FormItemContext.Provider value={value}>
179
+ <div data-slot="form-item" {...rest} className={cn("grid gap-1", className)} />
180
+ </FormItemContext.Provider>
181
+ );
182
+ }
183
+
184
+ /** Report a describing node's presence to the item, so the control points at it only
185
+ * while it is on screen. A layout effect, so the reference lands before paint. */
186
+ function useDescribes(slot: Described, on: boolean) {
187
+ const item = useContext(FormItemContext);
188
+ const mark = item?.mark;
189
+ useLayoutEffect(() => {
190
+ if (!on || !mark) return;
191
+ mark(slot, true);
192
+ return () => mark(slot, false);
193
+ }, [slot, on, mark]);
194
+ }
195
+
196
+ // ── FormLabel ────────────────────────────────────────────────────────────────
197
+
198
+ export type FormLabelProps = LabelProps;
199
+
200
+ /**
201
+ * The kit's {@link Label}, bound to the control by `htmlFor` and turned to the danger
202
+ * colour while the field is invalid. `required` draws the kit's `aria-hidden` mark:
203
+ * whether the field is required is the control's to announce.
204
+ */
205
+ export function FormLabel({ className, ...rest }: FormLabelProps) {
206
+ const { error, formItemId } = useFormField();
207
+ return (
208
+ <Label
209
+ data-slot="form-label"
210
+ data-error={error ? true : undefined}
211
+ htmlFor={formItemId}
212
+ {...rest}
213
+ className={cn("data-[error=true]:text-[var(--danger)]", className)}
214
+ />
215
+ );
216
+ }
217
+
218
+ // ── FormControl ──────────────────────────────────────────────────────────────
219
+
220
+ export interface FormControlProps {
221
+ /** Exactly one element: the kit field, or a native one. It receives `id`,
222
+ * `aria-describedby` and `aria-invalid`. */
223
+ children: ReactElement;
224
+ }
225
+
226
+ type AriaProps = {
227
+ id?: string;
228
+ "aria-describedby"?: string;
229
+ "aria-invalid"?: ComponentProps<"input">["aria-invalid"];
230
+ };
231
+
232
+ /**
233
+ * Wires its one child to the item: the `id` the label points at, `aria-describedby`
234
+ * naming the rendered description and message, and `aria-invalid` while the field has
235
+ * an error.
236
+ *
237
+ * The child's own values win where it has them (an explicit `id`, a set
238
+ * `aria-invalid`), as they did under Radix `Slot`; its own `aria-describedby` is
239
+ * merged in front of the item's rather than replaced.
240
+ */
241
+ export function FormControl({ children }: FormControlProps) {
242
+ const { invalid, formItemId, describedBy } = useFormField();
243
+ const child = Children.only(children);
244
+ if (!isValidElement<AriaProps>(child)) return child;
245
+ const own = child.props;
246
+ const merged = [own["aria-describedby"], describedBy].filter(Boolean).join(" ");
247
+ return cloneElement(child, {
248
+ id: own.id ?? formItemId,
249
+ "aria-describedby": merged || undefined,
250
+ "aria-invalid": own["aria-invalid"] ?? (invalid || undefined),
251
+ });
252
+ }
253
+
254
+ // ── FormDescription / FormMessage ────────────────────────────────────────────
255
+
256
+ export type FormDescriptionProps = ComponentProps<"p">;
257
+
258
+ /** Standing advice under the field ("at least twelve characters"). */
259
+ export function FormDescription({ className, ...rest }: FormDescriptionProps) {
260
+ const { formDescriptionId } = useFormField();
261
+ useDescribes("description", true);
262
+ return (
263
+ <p
264
+ data-slot="form-description"
265
+ {...rest}
266
+ id={formDescriptionId}
267
+ className={cn("text-[11px] leading-tight text-[var(--text-muted)]", className)}
268
+ />
269
+ );
270
+ }
271
+
272
+ export type FormMessageProps = ComponentProps<"p">;
273
+
274
+ /**
275
+ * The field's error message, from the form — the kit renders no string of its own
276
+ * here; the schema or the `rules` carry the words. Without an error it renders its
277
+ * `children`, if any, and otherwise nothing.
278
+ *
279
+ * Deliberately NOT `role="alert"`, for the reason the kit's `error` prop gives: the
280
+ * message is read when focus reaches the control, which is where a field's own error
281
+ * is wanted, and an alert would interrupt on every keystroke of a form that
282
+ * re-validates as you type.
283
+ */
284
+ export function FormMessage({ className, children, ...rest }: FormMessageProps) {
285
+ const { error, formMessageId } = useFormField();
286
+ const body = error ? String(error.message ?? "") : children;
287
+ const shown = body !== undefined && body !== null && body !== false && body !== "";
288
+ useDescribes("message", shown);
289
+ if (!shown) return null;
290
+ return (
291
+ <p
292
+ data-slot="form-message"
293
+ {...rest}
294
+ id={formMessageId}
295
+ className={cn("text-[11px] leading-tight text-[var(--danger)]", className)}
296
+ >
297
+ {body}
298
+ </p>
299
+ );
300
+ }
package/src/rhf.ts ADDED
@@ -0,0 +1,9 @@
1
+ // `@eifi1/ui-kit/rhf` — the react-hook-form adapter: Form, FormField, FormItem,
2
+ // FormLabel, FormControl, FormDescription, FormMessage and useFormField.
3
+ //
4
+ // The only entry point in the package that requires `react-hook-form`, and an optional
5
+ // peer for that reason: an app without a form library installs nothing for it. NOT
6
+ // re-exported from the main barrel, which would make the peer everyone's — the reason
7
+ // the barrel dropped its last react-hook-form import (see the wizard section of
8
+ // src/index.ts). The packaging contract test holds that line.
9
+ export * from "./rhf/form";
@@ -600,7 +600,9 @@ function SidebarGroup({
600
600
  aria-hidden
601
601
  className={cn(
602
602
  "size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100",
603
- open && "rotate-90 rtl:rotate-90",
603
+ // Under dir="rtl" the chevron is MIRRORED first (scale applies before rotate), so
604
+ // it starts pointing left and a +90° turn would point it UP — hence −90° there.
605
+ open && "rotate-90 rtl:-rotate-90",
604
606
  )}
605
607
  />
606
608
  </button>
@@ -0,0 +1,8 @@
1
+ // `@eifi1/ui-kit/table-text` — the lexer for a table pasted out of a spreadsheet or read
2
+ // from a CSV, and the three named rules for the comma. See the note at the top of
3
+ // lib/table-text.ts.
4
+ //
5
+ // Pure functions over strings: no React, no DOM, nothing to install. It is its own
6
+ // entry so an app that imports a CSV on a server, in a worker or in a script can take
7
+ // it without reaching through a barrel of components.
8
+ export * from "./lib/table-text";