@eifi1/ui-kit 0.5.0 → 0.5.1

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.
@@ -5,6 +5,8 @@ import { cn } from "../lib/cn";
5
5
  import { monthKey, pad } from "../lib/dates";
6
6
  import { FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER } from "./ui";
7
7
  import { Popover } from "./popover";
8
+ import { splitTriggerAria } from "./trigger-aria";
9
+ import type { TriggerAria } from "./trigger-aria";
8
10
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
9
11
 
10
12
  /**
@@ -156,6 +158,7 @@ function MonthFieldTrigger({
156
158
  disabled,
157
159
  invalid,
158
160
  className,
161
+ aria,
159
162
  }: {
160
163
  open: boolean;
161
164
  toggle: () => void;
@@ -163,6 +166,8 @@ function MonthFieldTrigger({
163
166
  triggerText: string;
164
167
  hasValue: boolean;
165
168
  labelledBy: string;
169
+ /** The caller's naming attributes — see `trigger-aria.ts`. */
170
+ aria: TriggerAria;
166
171
  valueId: string;
167
172
  panelId: string;
168
173
  padded: boolean;
@@ -191,8 +196,11 @@ function MonthFieldTrigger({
191
196
  aria-haspopup="dialog"
192
197
  aria-controls={panelId}
193
198
  aria-expanded={open}
194
- aria-invalid={invalid || undefined}
195
- aria-labelledby={labelledBy}
199
+ id={aria.id}
200
+ aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
201
+ aria-labelledby={aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy}
202
+ aria-label={aria["aria-label"]}
203
+ aria-describedby={aria["aria-describedby"]}
196
204
  className={cn(
197
205
  FIELD_TRIGGER,
198
206
  "pe-9",
@@ -488,6 +496,7 @@ export function MonthPicker({
488
496
  : (placeholder ?? "");
489
497
 
490
498
  const id = useId();
499
+ const [aria, wrapperRest] = splitTriggerAria(rest);
491
500
  const labelId = `${id}-label`;
492
501
  const valueId = `${id}-value`;
493
502
  const panelId = `${id}-panel`;
@@ -495,7 +504,7 @@ export function MonthPicker({
495
504
  const named = typeof label === "string";
496
505
 
497
506
  return (
498
- <div {...rest} className={cn("relative", className)}>
507
+ <div {...wrapperRest} className={cn("relative", className)}>
499
508
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
500
509
  {/* The hidden twin the trigger is named by — see DateField. `sr-only-fixed`,
501
510
  and inside this `relative` root either way. */}
@@ -515,7 +524,16 @@ export function MonthPicker({
515
524
  triggerRef={ref}
516
525
  triggerText={triggerText}
517
526
  hasValue={selected != null}
518
- labelledBy={named ? `${labelId} ${valueId}` : valueId}
527
+ // As DatePicker: a caller's reference first; an `id` without a label of our
528
+ // own references the trigger itself, so an external <label htmlFor> names it.
529
+ labelledBy={[
530
+ aria["aria-labelledby"],
531
+ named ? labelId : !aria["aria-labelledby"] && aria.id ? aria.id : undefined,
532
+ valueId,
533
+ ]
534
+ .filter(Boolean)
535
+ .join(" ")}
536
+ aria={aria}
519
537
  valueId={valueId}
520
538
  panelId={panelId}
521
539
  padded={label !== undefined}
@@ -19,7 +19,10 @@ export interface PopoverLabels {
19
19
  // Exported since the kit grew one label tree (`UiKitLabels`, src/i18n): the complete
20
20
  // English reference a translator works from has to be able to name every namespace.
21
21
  export const DEFAULT_POPOVER_LABELS: PopoverLabels = {
22
- panel: "Popover",
22
+ // "Pop-up", not "Popover": the panel's accessible name is read to USERS, and
23
+ // "popover" is a developer's word for it (reported by keksdose, 0.5.0). Only a
24
+ // bare Popover falls back to this — the kit's own pickers name their panels.
25
+ panel: "Pop-up",
23
26
  };
24
27
 
25
28
  /**
@@ -0,0 +1,42 @@
1
+ // Internal — not re-exported from the barrel. Shared by the pickers whose trigger is
2
+ // a `role="combobox"` button inside a wrapper: DatePicker, DateRangePicker, MonthPicker.
3
+
4
+ /**
5
+ * The attributes that NAME or DESCRIBE a field, which belong on its trigger — the
6
+ * element a `<label htmlFor>`, a form library's control slot or an error message has
7
+ * to reach. They used to land on the wrapper `<div>` with the rest of the caller's
8
+ * props, so an external label named nothing and a form library's `aria-describedby`
9
+ * pointed a screen reader at an element with no role (kastlan, 0.5.0).
10
+ */
11
+ export interface TriggerAria {
12
+ id?: string;
13
+ "aria-label"?: string;
14
+ "aria-labelledby"?: string;
15
+ "aria-describedby"?: string;
16
+ "aria-invalid"?: boolean | "true" | "false" | "grammar" | "spelling";
17
+ }
18
+
19
+ /** Split a field's props into what its trigger takes and what its wrapper takes. */
20
+ export function splitTriggerAria<T extends TriggerAria>(
21
+ props: T,
22
+ ): [TriggerAria, Omit<T, keyof TriggerAria>] {
23
+ const {
24
+ id,
25
+ "aria-label": label,
26
+ "aria-labelledby": labelledBy,
27
+ "aria-describedby": describedBy,
28
+ "aria-invalid": invalid,
29
+ ...rest
30
+ } = props;
31
+ return [
32
+ {
33
+ id,
34
+ "aria-label": label,
35
+ "aria-labelledby": labelledBy,
36
+ "aria-describedby": describedBy,
37
+ "aria-invalid": invalid,
38
+ },
39
+ rest,
40
+ ];
41
+ }
42
+
@@ -968,9 +968,17 @@ export function CardFooter({ className, ...props }: CardFooterProps) {
968
968
  /** A `<span>`'s props plus the words a reader hears — see {@link CardHeaderProps}.
969
969
  * The ring is drawn with a border, so there is nothing inside it to put children in. */
970
970
  export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
971
- /** What the spinner means, for a screen reader. Default: `common.loading` from the
972
- * {@link UiKitProvider}, else "Loading…". */
973
- label?: string;
971
+ /**
972
+ * What the spinner means, for a screen reader. Default: `common.loading` from the
973
+ * {@link UiKitProvider}, else "Loading…".
974
+ *
975
+ * `null` makes it DECORATIVE — no role, no text, hidden from assistive tech. Use it
976
+ * whenever words are already there: beside visible "Loading…" text (otherwise it is
977
+ * announced twice), inside a labelled button (otherwise its text joins the button's
978
+ * name), or inside a live region of the app's own (otherwise that region and this
979
+ * one both announce).
980
+ */
981
+ label?: string | null;
974
982
  }
975
983
 
976
984
  /**
@@ -984,22 +992,25 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
984
992
  * ignore a name on one.
985
993
  *
986
994
  * `relative` so the `sr-only` text has a local containing block (see
987
- * sr-only-containment.test). The role goes BEFORE the spread: a caller showing the
988
- * spinner next to text that already says "Loading" can pass `aria-hidden` or its own
989
- * `role` and have it win.
995
+ * sr-only-containment.test). Where the words are already on screen, pass
996
+ * `label={null}`: announcing is right for a spinner standing alone and wrong for one
997
+ * beside text, inside a button, or inside the app's own live region.
990
998
  */
991
999
  export function Spinner({ className, label, ...rest }: SpinnerProps) {
992
- const common = useKitLabels("common", DEFAULT_COMMON_LABELS, { loading: label });
1000
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
1001
+ loading: label ?? undefined,
1002
+ });
1003
+ const ring = cn(
1004
+ "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
1005
+ className,
1006
+ );
1007
+ if (label === null) return <span aria-hidden {...rest} className={ring} />;
993
1008
  return (
994
- <span
995
- role="status"
996
- {...rest}
997
- className={cn(
998
- "relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
999
- className,
1000
- )}
1001
- >
1002
- <span className="sr-only">{common.loading}</span>
1009
+ <span role="status" {...rest} className={ring}>
1010
+ {/* The trailing space is a separator for the case the caller forgot `label={null}`
1011
+ inside a button: a name is the concatenation of its text, and without it the
1012
+ spinner's word ran straight into the button's — "Loadingconfirm". */}
1013
+ <span className="sr-only">{common.loading} </span>
1003
1014
  </span>
1004
1015
  );
1005
1016
  }