@codapet/design-system 0.8.6 → 0.8.7

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/AGENTS.md CHANGED
@@ -369,6 +369,8 @@ There are no other public exports, and no subpath imports: only
369
369
  - **Wrong default Button look** → you forgot `variant="primary"`/`size="md"` are not the same as shadcn's defaults; passing nothing gives you `primary` + `lg`.
370
370
  - **Toast looks generic** → you imported `toast` from `'sonner'` instead of from `@codapet/design-system`.
371
371
  - **Modal doesn't bottom-sheet on mobile** → you used `Dialog` instead of `SmartDialog`.
372
+ - **`AsyncAutocomplete` keeps stale results while refetching** → `loading` only replaces the list when there is nothing else to show. Once results are visible, a further keystroke leaves them in place and marks the listbox `aria-busy` plus `data-busy`, so the panel does not flash back to the spinner on every character. Style the refresh with `classNames={{ listbox: 'data-[busy=true]:opacity-60' }}` if you want visible feedback.
373
+ - **`AsyncAutocomplete` warns about a missing accessible name** → a combobox must be named, and a `placeholder` does not name one (ARIA practices are explicit about this). Pass `label` for a visible label — it renders a real `<label htmlFor>`, unlike `SearchInput`/`MultiSelectFreeText`, whose `label` prop is a styled `<div>` and is *not* programmatically associated — or pass `inputProps={{ 'aria-label': '…' }}` when the design has no visible label. `mandatory` sets `aria-required` and hides the asterisk from assistive tech.
372
374
  - **`AsyncAutocomplete` fires a request per keystroke** → it deliberately does not debounce. Debounce in your fetch layer (`usePlacesService({ debounce: 300 })`, your own hook) so cancellation and out-of-order responses stay with the code that owns them.
373
375
  - **`AsyncAutocomplete` shows results that don't match what you typed** → it renders `options` verbatim by design, because the server already filtered. If your list is static, use `SearchableSelect`/`DropdownSelect`, which filter client-side.
374
376
  - **Styling `AsyncAutocomplete`** → it does **not** follow the flat `*ClassName` convention that `DateInput`/`TimeInput` use, because it has twenty-odd stylable slots. `className` is the root wrapper; everything else lives in one `classNames` object: `field`, `input`, `leftIcon`, `clearButton`, `content`, `listbox`, `option`, `optionHighlighted`, `optionDisabled`, `optionIcon`, `optionLabel`, `optionDescription`, `loading`, `loadingSpinner`, `empty`, plus `sheetOverlay`, `sheetContent`, `sheetHeader`, `sheetCloseButton`, `sheetInput`, `sheetList`. All merge through `cn`, so `{ content: 'max-h-[420px]' }` replaces the built-in max-height rather than stacking with it. A single row can also carry its own `className` via the option object, and rows expose `data-highlighted` / `data-disabled` for parent-level selectors. Use `renderOption` to change row *structure*, `classNames` to change its *look*.
package/dist/index.d.mts CHANGED
@@ -140,6 +140,8 @@ type AsyncAutocompleteInputProps = Omit<React$1.ComponentPropsWithoutRef<'input'
140
140
  interface AsyncAutocompleteClassNames {
141
141
  /** Wrapper around the input — also the popover's anchor. */
142
142
  field?: string;
143
+ /** The visible label element. */
144
+ label?: string;
143
145
  /** The text input itself. */
144
146
  input?: string;
145
147
  /** Left icon wrapper inside the input. */
@@ -202,7 +204,14 @@ interface AsyncAutocompleteProps<TData = unknown> {
202
204
  * `formatted_address`) or clear the field. Drive `value` yourself.
203
205
  */
204
206
  onSelect: (option: AsyncAutocompleteOption<TData>) => void;
205
- /** In-flight request. Replaces the rows with a spinner. */
207
+ /**
208
+ * A request is in flight.
209
+ *
210
+ * The spinner row only replaces the list when there is nothing else to show.
211
+ * If results are already on screen they stay put and the listbox is marked
212
+ * `aria-busy` / `data-busy` instead — so typing another character does not
213
+ * flash the panel back to "Searching…".
214
+ */
206
215
  loading?: boolean;
207
216
  /** Controlled input text. */
208
217
  value?: string;
@@ -217,6 +226,17 @@ interface AsyncAutocompleteProps<TData = unknown> {
217
226
  */
218
227
  onClear?: () => void;
219
228
  placeholder?: string;
229
+ /**
230
+ * Visible label, rendered above the field and wired to it with `htmlFor`.
231
+ *
232
+ * A combobox **must** have an accessible name — the ARIA practices are
233
+ * explicit that a placeholder is not one. Provide either this, or an
234
+ * `aria-label`/`aria-labelledby` through `inputProps`. In development the
235
+ * component warns when neither is present.
236
+ */
237
+ label?: string;
238
+ /** Appends a red asterisk to `label`. */
239
+ mandatory?: boolean;
220
240
  /** Decorative leading icon — `<MapPin />`, `<Stethoscope />`, … */
221
241
  leftIcon?: React$1.ReactNode;
222
242
  /** Fixed height: `sm` 40px · `md` 48px (default) · `lg` 56px. */
@@ -241,6 +261,10 @@ interface AsyncAutocompleteProps<TData = unknown> {
241
261
  * for stacked two-line rows, avatars, or bold match highlighting — the
242
262
  * wrapper, `role="option"`, ids, highlight background and click/keyboard
243
263
  * wiring stay with the component.
264
+ *
265
+ * Keep the content non-interactive: it renders inside `role="option"`, and a
266
+ * nested button or link there is both invalid and unreachable by keyboard,
267
+ * since the row is driven by `aria-activedescendant` rather than focus.
244
268
  */
245
269
  renderOption?: (option: AsyncAutocompleteOption<TData>, state: AsyncAutocompleteOptionState) => React$1.ReactNode;
246
270
  /** Default leading icon for rows without their own `option.icon`. */
@@ -280,7 +304,7 @@ interface AsyncAutocompleteProps<TData = unknown> {
280
304
  /** Per-slot class overrides. See `AsyncAutocompleteClassNames`. */
281
305
  classNames?: AsyncAutocompleteClassNames;
282
306
  }
283
- declare function AsyncAutocomplete<TData = unknown>({ options, onSearch, onSelect, loading, value: valueProp, defaultValue, onValueChange, onClear, placeholder, leftIcon, size, error, disabled, inputRef, inputProps, mobileVariant, renderOption, optionIcon, emptyMessage, emptyState, loadingMessage, clearIcon, clearLabel, sheetTitle, sheetCloseIcon, sheetCloseLabel, open: openProp, defaultOpen, onOpenChange, idPrefix, className, classNames }: AsyncAutocompleteProps<TData>): react_jsx_runtime.JSX.Element;
307
+ declare function AsyncAutocomplete<TData = unknown>({ options, onSearch, onSelect, loading, value: valueProp, defaultValue, onValueChange, onClear, placeholder, label, mandatory, leftIcon, size, error, disabled, inputRef, inputProps, mobileVariant, renderOption, optionIcon, emptyMessage, emptyState, loadingMessage, clearIcon, clearLabel, sheetTitle, sheetCloseIcon, sheetCloseLabel, open: openProp, defaultOpen, onOpenChange, idPrefix, className, classNames }: AsyncAutocompleteProps<TData>): react_jsx_runtime.JSX.Element;
284
308
 
285
309
  interface TextareaProps extends Omit<React$1.ComponentProps<'textarea'>, 'size'> {
286
310
  error?: boolean;
package/dist/index.mjs CHANGED
@@ -664,6 +664,8 @@ function AsyncAutocomplete({
664
664
  onValueChange,
665
665
  onClear,
666
666
  placeholder,
667
+ label,
668
+ mandatory = false,
667
669
  leftIcon,
668
670
  size = "md",
669
671
  error = false,
@@ -702,6 +704,13 @@ function AsyncAutocomplete({
702
704
  const sheetInputRef = React8.useRef(null);
703
705
  const optionRefs = React8.useRef([]);
704
706
  const isMobile = useIsMobile();
707
+ const hasAccessibleName = !!label || !!inputProps?.["aria-label"] || !!inputProps?.["aria-labelledby"];
708
+ React8.useEffect(() => {
709
+ if (process.env.NODE_ENV === "production" || hasAccessibleName) return;
710
+ console.warn(
711
+ "[AsyncAutocomplete] No accessible name. Pass `label`, or an `aria-label`/`aria-labelledby` via `inputProps`. A `placeholder` does not name a combobox."
712
+ );
713
+ }, [hasAccessibleName]);
705
714
  const isControlledValue = valueProp !== void 0;
706
715
  const isControlledOpen = openProp !== void 0;
707
716
  const query = isControlledValue ? valueProp : internalValue;
@@ -796,13 +805,13 @@ function AsyncAutocomplete({
796
805
  if (highlightedIndex < 0) return;
797
806
  optionRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" });
798
807
  }, [highlightedIndex]);
799
- const showEmpty = !loading && options.length === 0 && query.trim() !== "";
800
- const hasBody = loading || options.length > 0 || showEmpty;
808
+ const hasOptions = options.length > 0;
809
+ const showLoadingRow = loading && !hasOptions;
810
+ const showEmpty = !loading && !hasOptions && query.trim() !== "";
811
+ const isRefreshing = loading && hasOptions;
812
+ const hasBody = showLoadingRow || hasOptions || showEmpty;
801
813
  const popoverOpen = isOpen && !useSheet && hasBody;
802
- const dismissIgnoreRefs = React8.useMemo(
803
- () => [contentRef, fieldRef],
804
- []
805
- );
814
+ const dismissIgnoreRefs = React8.useMemo(() => [contentRef, fieldRef], []);
806
815
  useDismissOnScroll(popoverOpen, closePanel, dismissIgnoreRefs);
807
816
  const handleKeyDown = (event) => {
808
817
  if (useSheet && !isOpen && (event.key === "Enter" || event.key === " " || event.key === "ArrowDown")) {
@@ -890,7 +899,16 @@ function AsyncAutocomplete({
890
899
  ),
891
900
  children: [
892
901
  option.label,
893
- option.description && /* @__PURE__ */ jsx8("span", { className: cn("text-gray-subtle", classNames?.optionDescription), children: `, ${option.description}` })
902
+ option.description && /* @__PURE__ */ jsx8(
903
+ "span",
904
+ {
905
+ className: cn(
906
+ "text-gray-subtle",
907
+ classNames?.optionDescription
908
+ ),
909
+ children: `, ${option.description}`
910
+ }
911
+ )
894
912
  ]
895
913
  }
896
914
  )
@@ -904,22 +922,71 @@ function AsyncAutocomplete({
904
922
  {
905
923
  role: "listbox",
906
924
  id: listboxId,
925
+ "aria-busy": loading || void 0,
926
+ "data-busy": isRefreshing || void 0,
907
927
  className: cn("flex flex-col", classNames?.listbox),
908
- children: loading ? /* @__PURE__ */ jsxs5("div", { className: cn(messageClassName, classNames?.loading), children: [
909
- /* @__PURE__ */ jsx8(
910
- Loader2,
928
+ children: showLoadingRow ? (
929
+ // `role="listbox"` may only own `option` and `group` children, so the
930
+ // status rows are disabled options: still announced, never selectable,
931
+ // and skipped by keyboard nav since they are not in `options`.
932
+ /* @__PURE__ */ jsxs5(
933
+ "div",
911
934
  {
912
- className: cn(
913
- "size-[16px] animate-spin text-gray-icon-light",
914
- classNames?.loadingSpinner
915
- )
935
+ role: "option",
936
+ "aria-disabled": "true",
937
+ "aria-selected": "false",
938
+ className: cn(messageClassName, classNames?.loading),
939
+ children: [
940
+ /* @__PURE__ */ jsx8(
941
+ Loader2,
942
+ {
943
+ className: cn(
944
+ "size-[16px] animate-spin text-gray-icon-light",
945
+ classNames?.loadingSpinner
946
+ )
947
+ }
948
+ ),
949
+ loadingMessage
950
+ ]
916
951
  }
917
- ),
918
- loadingMessage
919
- ] }) : showEmpty ? emptyState ?? /* @__PURE__ */ jsx8("div", { className: cn(messageClassName, classNames?.empty), children: emptyMessage }) : options.map(renderRow)
952
+ )
953
+ ) : showEmpty ? /* @__PURE__ */ jsx8(
954
+ "div",
955
+ {
956
+ role: "option",
957
+ "aria-disabled": "true",
958
+ "aria-selected": "false",
959
+ className: cn(!emptyState && messageClassName, classNames?.empty),
960
+ children: emptyState ?? emptyMessage
961
+ }
962
+ ) : options.map(renderRow)
920
963
  }
921
964
  );
922
965
  const activeDescendant = highlightedIndex >= 0 ? optionDomId(highlightedIndex) : void 0;
966
+ const labelNode = label ? /* @__PURE__ */ jsxs5(
967
+ "label",
968
+ {
969
+ htmlFor: `${prefix}-input`,
970
+ "data-slot": "async-autocomplete-label",
971
+ className: cn(
972
+ "flex items-center font-sans font-medium text-[14px] leading-[20px] text-vibrant-text-details",
973
+ classNames?.label
974
+ ),
975
+ children: [
976
+ label,
977
+ mandatory && // Decorative: the requirement is conveyed by aria-required on the
978
+ // input, so screen readers say "City, required" rather than "City star".
979
+ /* @__PURE__ */ jsx8(
980
+ "span",
981
+ {
982
+ "aria-hidden": "true",
983
+ className: "ml-0.5 text-[14px] leading-[20px] text-error-surface-default",
984
+ children: "*"
985
+ }
986
+ )
987
+ ]
988
+ }
989
+ ) : null;
923
990
  const field = /* @__PURE__ */ jsx8("div", { ref: fieldRef, className: cn("relative w-full", classNames?.field), children: /* @__PURE__ */ jsx8(
924
991
  Input,
925
992
  {
@@ -933,6 +1000,7 @@ function AsyncAutocomplete({
933
1000
  "aria-expanded": useSheet ? sheetIsOpen : popoverOpen,
934
1001
  "aria-controls": popoverOpen ? listboxId : void 0,
935
1002
  "aria-autocomplete": useSheet ? void 0 : "list",
1003
+ "aria-required": mandatory || void 0,
936
1004
  "aria-activedescendant": popoverOpen ? activeDescendant : void 0,
937
1005
  size,
938
1006
  error,
@@ -971,8 +1039,13 @@ function AsyncAutocomplete({
971
1039
  "div",
972
1040
  {
973
1041
  "data-slot": "async-autocomplete",
974
- className: cn("w-full", className),
1042
+ className: cn(
1043
+ "flex w-full flex-col",
1044
+ labelNode && "gap-[8px]",
1045
+ className
1046
+ ),
975
1047
  children: [
1048
+ labelNode,
976
1049
  /* @__PURE__ */ jsx8(PopoverPrimitive.Anchor, { asChild: true, children: field }),
977
1050
  /* @__PURE__ */ jsx8(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx8(
978
1051
  PopoverPrimitive.Content,
@@ -1076,7 +1149,7 @@ function AsyncAutocomplete({
1076
1149
  "aria-controls": listboxId,
1077
1150
  "aria-autocomplete": "list",
1078
1151
  "aria-activedescendant": activeDescendant,
1079
- "aria-label": sheetTitle,
1152
+ "aria-label": label ?? sheetTitle,
1080
1153
  size,
1081
1154
  placeholder,
1082
1155
  leftIcon,