@godxjp/ui 27.1.0 → 27.4.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.
Files changed (74) hide show
  1. package/dist/components/data-display/badge.d.ts +10 -3
  2. package/dist/components/data-display/badge.js +14 -3
  3. package/dist/components/data-display/data-table.d.ts +20 -12
  4. package/dist/components/data-display/data-table.js +53 -5
  5. package/dist/components/data-display/range-timeline.d.ts +16 -0
  6. package/dist/components/data-display/range-timeline.js +115 -80
  7. package/dist/components/data-display/table.d.ts +16 -0
  8. package/dist/components/data-display/table.js +2 -0
  9. package/dist/components/data-entry/checkbox.js +23 -5
  10. package/dist/components/data-entry/command.d.ts +3 -1
  11. package/dist/components/data-entry/command.js +10 -2
  12. package/dist/components/data-entry/form-field.js +9 -8
  13. package/dist/components/data-entry/index.d.ts +1 -0
  14. package/dist/components/data-entry/search-select.js +145 -136
  15. package/dist/components/data-entry/select.js +38 -3
  16. package/dist/components/general/icon.d.ts +31 -0
  17. package/dist/components/general/icon.js +23 -0
  18. package/dist/components/general/index.d.ts +2 -0
  19. package/dist/components/general/index.js +2 -0
  20. package/dist/components/layout/flex.d.ts +2 -2
  21. package/dist/components/layout/flex.js +7 -1
  22. package/dist/components/layout/org-switcher.js +1 -1
  23. package/dist/components/layout/responsive-grid.d.ts +8 -2
  24. package/dist/components/layout/responsive-grid.js +2 -0
  25. package/dist/components/layout/topbar-item.d.ts +1 -0
  26. package/dist/components/layout/topbar-item.js +3 -1
  27. package/dist/contracts/measurement.json +1 -1
  28. package/dist/form/form-field-control.d.ts +1 -1
  29. package/dist/form/form-field-control.js +32 -23
  30. package/dist/i18n/messages/en.json +2 -1
  31. package/dist/i18n/messages/ja.json +2 -1
  32. package/dist/i18n/messages/vi.json +2 -1
  33. package/dist/props/components/data-display.prop.d.ts +12 -0
  34. package/dist/props/components/data-entry.prop.d.ts +10 -0
  35. package/dist/props/components/form.prop.d.ts +34 -2
  36. package/dist/props/components/general.prop.d.ts +42 -1
  37. package/dist/props/components/index.d.ts +1 -1
  38. package/dist/props/components/layout.prop.d.ts +49 -0
  39. package/dist/props/registry.d.ts +57 -3
  40. package/dist/props/registry.js +62 -2
  41. package/dist/props/vocabulary/data.prop.d.ts +27 -1
  42. package/dist/props/vocabulary/index.d.ts +2 -2
  43. package/dist/props/vocabulary/interaction.prop.d.ts +11 -0
  44. package/dist/styles/control.css +44 -1
  45. package/dist/styles/core.css +2 -0
  46. package/dist/styles/data-display-layout.css +58 -1
  47. package/dist/styles/form-layout.css +21 -0
  48. package/dist/styles/icon-layout.css +37 -0
  49. package/dist/styles/index.css +2 -0
  50. package/dist/styles/layout.css +7 -0
  51. package/dist/styles/navigation-layout.css +5 -1
  52. package/dist/styles/shell-layout.css +11 -14
  53. package/dist/styles/table-layout.css +80 -8
  54. package/dist/styles/text-layout.css +16 -8
  55. package/dist/tokens/components/control.css +5 -0
  56. package/dist/tokens/components/data-display.css +6 -0
  57. package/dist/tokens/components/navigation.css +1 -1
  58. package/dist/tokens/components/table.css +2 -0
  59. package/docs/CONSUMER-RULES.md +2 -2
  60. package/docs/DESIGN-AUTHORITY.md +32 -1
  61. package/docs/FRAME-COVERAGE-REPORT.md +3 -2
  62. package/docs/data-display/data-table/examples/server-paged.tsx +156 -0
  63. package/docs/data-display/data-table/examples/striped-list.tsx +173 -0
  64. package/docs/data-display/popover.tsx +43 -1
  65. package/docs/data-display/timeline.tsx +73 -9
  66. package/docs/data-entry/command.tsx +80 -1
  67. package/docs/data-entry/form-root.tsx +12 -2
  68. package/docs/general/icon.tsx +215 -0
  69. package/docs/layout/flex.tsx +59 -0
  70. package/docs/layout/responsive-grid.tsx +100 -0
  71. package/docs/layout/topbar-item.tsx +29 -1
  72. package/package.json +3 -2
  73. package/scripts/consumer-rule.md +18 -17
  74. package/scripts/ui-audit.mjs +128 -0
@@ -1,10 +1,11 @@
1
1
  "use client";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Checkbox as AriaCheckbox } from "react-aria-components";
5
5
  import { Check, Minus } from "lucide-react";
6
6
  import { cn } from "../../lib/utils.js";
7
- import { useFieldIdentity } from "../../lib/field-a11y.js";
7
+ import { mergeAriaIds, useFieldIdentity } from "../../lib/field-a11y.js";
8
+ import { Label } from "./label.js";
8
9
  import { CheckboxGroup } from "./checkbox-group.js";
9
10
  import { withOwnHitTarget } from "./choice-hit-target.js";
10
11
  function CheckboxGlyph({ state }) {
@@ -39,9 +40,14 @@ const CheckboxRoot = React.forwardRef((props, ref) => {
39
40
  required,
40
41
  value,
41
42
  tabIndex,
43
+ children,
42
44
  "data-field": ownField,
43
45
  ...rest
44
46
  } = props;
47
+ const autoId = React.useId();
48
+ const hasInlineLabel = !(children === void 0 || children === null || children === false || children === "");
49
+ const inputId = props.id ?? (hasInlineLabel ? autoId : void 0);
50
+ const inlineLabelId = `${inputId}-inline-label`;
45
51
  const identity = useFieldIdentity({
46
52
  id: props.id,
47
53
  name: props.name,
@@ -63,7 +69,10 @@ const CheckboxRoot = React.forwardRef((props, ref) => {
63
69
  );
64
70
  const state = checked ?? uncontrolled;
65
71
  const invalid = props["aria-invalid"];
66
- return /* @__PURE__ */ jsx(
72
+ const isInvalid = invalid !== void 0 && invalid !== false && invalid !== "false";
73
+ const describedBy = isInvalid ? mergeAriaIds(props["aria-describedby"], props["aria-errormessage"]) : props["aria-describedby"];
74
+ const labelledBy = hasInlineLabel && props["aria-labelledby"] !== void 0 ? mergeAriaIds(props["aria-labelledby"], inlineLabelId) : props["aria-labelledby"];
75
+ const box = /* @__PURE__ */ jsx(
67
76
  AriaCheckbox,
68
77
  {
69
78
  ...rest,
@@ -77,7 +86,10 @@ const CheckboxRoot = React.forwardRef((props, ref) => {
77
86
  isIndeterminate: state === "indeterminate",
78
87
  isDisabled: disabled,
79
88
  isRequired: required,
80
- isInvalid: invalid !== void 0 && invalid !== false && invalid !== "false",
89
+ isInvalid,
90
+ id: inputId,
91
+ "aria-describedby": describedBy,
92
+ "aria-labelledby": labelledBy,
81
93
  excludeFromTabOrder: tabIndex !== void 0 && tabIndex < 0,
82
94
  className: cn(
83
95
  // `disabled:cursor-not-allowed disabled:opacity-50` and the checked fill are DELETED, not
@@ -95,7 +107,8 @@ const CheckboxRoot = React.forwardRef((props, ref) => {
95
107
  // 16px box collapses to nothing. CheckboxVisual already carries the same three for the
96
108
  // same reason.
97
109
  "peer ui-checkbox data-[invalid]:border-destructive data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground inline-flex shrink-0 items-center justify-center shadow-xs transition-shadow",
98
- className
110
+ // With an inline label the row is what a caller styles; the box keeps its own classes.
111
+ hasInlineLabel ? void 0 : className
99
112
  ),
100
113
  render: (domProps) => /* @__PURE__ */ jsx("label", { ...domProps, children: withOwnHitTarget(domProps.children) }),
101
114
  onChange: (next) => {
@@ -105,6 +118,11 @@ const CheckboxRoot = React.forwardRef((props, ref) => {
105
118
  children: /* @__PURE__ */ jsx("span", { "data-slot": "checkbox-indicator", className: "ui-choice-indicator", children: state ? /* @__PURE__ */ jsx(CheckboxGlyph, { state }) : null })
106
119
  }
107
120
  );
121
+ if (!hasInlineLabel) return box;
122
+ return /* @__PURE__ */ jsxs("div", { "data-slot": "checkbox-field", className: cn("ui-choice-field", className), children: [
123
+ /* @__PURE__ */ jsx("div", { className: "ui-choice-control", children: box }),
124
+ /* @__PURE__ */ jsx("div", { className: "ui-choice-content", children: /* @__PURE__ */ jsx(Label, { id: inlineLabelId, htmlFor: inputId, className: "ui-choice-label", children }) })
125
+ ] });
108
126
  });
109
127
  CheckboxRoot.displayName = "Checkbox";
110
128
  const Checkbox = Object.assign(CheckboxRoot, {
@@ -1,4 +1,6 @@
1
1
  import * as React from "react";
2
+ import type { CommandProp } from "../../props/components/data-entry.prop.js";
3
+ export type { CommandProp, CommandProp as CommandProps };
2
4
  export declare const Command: React.ForwardRefExoticComponent<Omit<{
3
5
  children?: React.ReactNode;
4
6
  } & Pick<Pick<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof React.HTMLAttributes<HTMLDivElement>> & {
@@ -15,7 +17,7 @@ export declare const Command: React.ForwardRefExoticComponent<Omit<{
15
17
  loop?: boolean;
16
18
  disablePointerSelection?: boolean;
17
19
  vimBindings?: boolean;
18
- } & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
20
+ } & React.RefAttributes<HTMLDivElement>, "ref"> & CommandProp & React.RefAttributes<HTMLDivElement>>;
19
21
  export declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof React.InputHTMLAttributes<HTMLInputElement>> & {
20
22
  ref?: React.Ref<HTMLInputElement>;
21
23
  } & {
@@ -5,7 +5,7 @@ import { Command as CommandPrimitive } from "cmdk";
5
5
  import { Search } from "lucide-react";
6
6
  import { cn } from "../../lib/utils.js";
7
7
  import { controlIconLeadingClass } from "../../lib/control-styles.js";
8
- const Command = React.forwardRef(({ className, ...props }, ref) => {
8
+ const Command = React.forwardRef(({ className, split = false, ...props }, ref) => {
9
9
  const rootRef = React.useRef(null);
10
10
  const setRefs = (node) => {
11
11
  rootRef.current = node;
@@ -22,7 +22,15 @@ const Command = React.forwardRef(({ className, ...props }, ref) => {
22
22
  if (input && expectedId && !input.id) input.id = expectedId;
23
23
  else if (!input || input.id !== expectedId) label.removeAttribute("for");
24
24
  });
25
- return /* @__PURE__ */ jsx(CommandPrimitive, { ref: setRefs, className: cn("ui-command", className), ...props });
25
+ return /* @__PURE__ */ jsx(
26
+ CommandPrimitive,
27
+ {
28
+ ref: setRefs,
29
+ className: cn("ui-command", className),
30
+ "data-split": split ? "" : void 0,
31
+ ...props
32
+ }
33
+ );
26
34
  });
27
35
  Command.displayName = CommandPrimitive.displayName;
28
36
  const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
@@ -47,6 +47,7 @@ function FormField({
47
47
  const autoId = React.useId();
48
48
  const resolvedId = id ?? autoId;
49
49
  const labelId = `${resolvedId}-label`;
50
+ const hasLabel = !(label === void 0 || label === null || label === false || label === "");
50
51
  const helperId = helper ? `${resolvedId}-helper` : void 0;
51
52
  const helperNode = helper ? /* @__PURE__ */ jsx("p", { id: helperId, className: "ui-form-field-note text-muted-foreground text-xs", children: helper }) : null;
52
53
  const validationStatus = error ? "error" : validateStatus;
@@ -62,8 +63,8 @@ function FormField({
62
63
  );
63
64
  }
64
65
  const fieldNameContext = React.useMemo(
65
- () => ({ labelId, label: typeof label === "string" ? label : void 0 }),
66
- [labelId, label]
66
+ () => hasLabel ? { labelId, label: typeof label === "string" ? label : void 0 } : null,
67
+ [hasLabel, labelId, label]
67
68
  );
68
69
  const fieldIdentityContext = React.useMemo(
69
70
  () => fieldKey === void 0 ? null : { emitName: emitFieldNames },
@@ -88,12 +89,12 @@ function FormField({
88
89
  // `"data-field": fieldKey` would erase a value the control set for itself.
89
90
  "data-field": childProps?.["data-field"] ?? fieldKey,
90
91
  ...emitFieldNames ? { name: childProps?.name ?? fieldKey } : void 0,
91
- "aria-labelledby": childProps?.["aria-labelledby"] ?? labelId,
92
+ "aria-labelledby": childProps?.["aria-labelledby"] ?? (hasLabel ? labelId : void 0),
92
93
  // Redundant `aria-label` fallback (belt-and-suspenders): the accessible name is the
93
94
  // SAME string as the visible label, just reachable even if an aria-labelledby lookup
94
95
  // ever comes back empty (id-ref timing, AT quirks). Only when `label` is plain text and
95
96
  // the child hasn't already set its own aria-label.
96
- "aria-label": childProps?.["aria-label"] ?? (typeof label === "string" ? label : void 0),
97
+ "aria-label": childProps?.["aria-label"] ?? (hasLabel && typeof label === "string" ? label : void 0),
97
98
  // Helper and error can coexist: helper stays on aria-describedby, the error on
98
99
  // aria-errormessage (surfaced when aria-invalid is true).
99
100
  "aria-describedby": mergeIds(
@@ -124,8 +125,8 @@ function FormField({
124
125
  style: Object.keys(style).length ? style : void 0,
125
126
  className: cn("ui-form-field", className),
126
127
  children: [
127
- /* @__PURE__ */ jsxs("div", { "data-slot": "form-field-label", className: "ui-form-field-label", children: [
128
- /* @__PURE__ */ jsx(
128
+ hasLabel || labelAddon != null ? /* @__PURE__ */ jsxs("div", { "data-slot": "form-field-label", className: "ui-form-field-label", children: [
129
+ hasLabel ? /* @__PURE__ */ jsx(
129
130
  Label,
130
131
  {
131
132
  asChild: true,
@@ -151,9 +152,9 @@ function FormField({
151
152
  }
152
153
  )
153
154
  }
154
- ),
155
+ ) : null,
155
156
  labelAddon
156
- ] }),
157
+ ] }) : null,
157
158
  /* @__PURE__ */ jsxs("div", { "data-slot": "form-field-control", className: "ui-form-field-control", children: [
158
159
  helperPlacement === "before" ? helperNode : null,
159
160
  isStatic ? childWithA11y : /* @__PURE__ */ jsx(FieldNameContext.Provider, { value: fieldNameContext, children: /* @__PURE__ */ jsx(FieldIdentityContext.Provider, { value: fieldIdentityContext, children: /* @__PURE__ */ jsx(
@@ -49,6 +49,7 @@ export type { TreeSelectProps } from "./tree-select.js";
49
49
  export { Transfer } from "./transfer.js";
50
50
  export type { TransferProps, TransferItemProp } from "./transfer.js";
51
51
  export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "./command.js";
52
+ export type { CommandProps } from "./command.js";
52
53
  export { CommandPalette } from "./command-palette.js";
53
54
  export type { CommandPaletteGroup, CommandPaletteItem, CommandPaletteLabels, CommandPaletteProps, } from "./command-palette.js";
54
55
  export { PasswordInput } from "./password-input.js";
@@ -511,144 +511,153 @@ function SearchSelect(props) {
511
511
  style: typeof popupMatchSelectWidth === "number" ? {
512
512
  "--search-select-panel-inline-size": `${popupMatchSelectWidth}px`
513
513
  } : void 0,
514
- children: /* @__PURE__ */ jsx(PopupRender, { render: popupRender, children: /* @__PURE__ */ jsxs(Command, { value, shouldFilter: false, className: "ui-search-select-command", children: [
515
- /* @__PURE__ */ jsx("div", { className: "ui-search-select-search", children: /* @__PURE__ */ jsx(
516
- Input,
517
- {
518
- autoFocus: true,
519
- "aria-controls": listId,
520
- "aria-autocomplete": "list",
521
- "aria-activedescendant": activeOptionId,
522
- "aria-label": searchPlaceholder ?? t("dataEntry.searchSelect.search"),
523
- value: query,
524
- onChange: (event) => {
525
- const next = event.target.value;
526
- if (multiple && tokenSeparators?.length && tokenSeparators.some((separator) => next.includes(separator))) {
527
- selectTokens(next);
528
- setQuery("");
529
- return;
514
+ children: /* @__PURE__ */ jsx(PopupRender, { render: popupRender, children: /* @__PURE__ */ jsxs(
515
+ Command,
516
+ {
517
+ value,
518
+ shouldFilter: false,
519
+ className: "ui-search-select-command",
520
+ split: multiple,
521
+ children: [
522
+ /* @__PURE__ */ jsx("div", { className: "ui-search-select-search", children: /* @__PURE__ */ jsx(
523
+ Input,
524
+ {
525
+ autoFocus: true,
526
+ "aria-controls": listId,
527
+ "aria-autocomplete": "list",
528
+ "aria-activedescendant": activeOptionId,
529
+ "aria-label": searchPlaceholder ?? t("dataEntry.searchSelect.search"),
530
+ value: query,
531
+ onChange: (event) => {
532
+ const next = event.target.value;
533
+ if (multiple && tokenSeparators?.length && tokenSeparators.some((separator) => next.includes(separator))) {
534
+ selectTokens(next);
535
+ setQuery("");
536
+ return;
537
+ }
538
+ setQuery(next);
539
+ },
540
+ onPaste: (event) => {
541
+ if (!multiple || !tokenSeparators?.length) return;
542
+ const text = event.clipboardData.getData("text");
543
+ if (!tokenSeparators.some((separator) => text.includes(separator))) return;
544
+ event.preventDefault();
545
+ selectTokens(text);
546
+ setQuery("");
547
+ },
548
+ onKeyDown,
549
+ placeholder: searchPlaceholder ?? t("dataEntry.searchSelect.search"),
550
+ className: "ui-search-select-search-input"
530
551
  }
531
- setQuery(next);
532
- },
533
- onPaste: (event) => {
534
- if (!multiple || !tokenSeparators?.length) return;
535
- const text = event.clipboardData.getData("text");
536
- if (!tokenSeparators.some((separator) => text.includes(separator))) return;
537
- event.preventDefault();
538
- selectTokens(text);
539
- setQuery("");
540
- },
541
- onKeyDown,
542
- placeholder: searchPlaceholder ?? t("dataEntry.searchSelect.search"),
543
- className: "ui-search-select-search-input"
544
- }
545
- ) }),
546
- /* @__PURE__ */ jsxs(
547
- "div",
548
- {
549
- id: listId,
550
- role: "listbox",
551
- "aria-multiselectable": multiple || void 0,
552
- "aria-busy": loading,
553
- className: "ui-search-select-list",
554
- style: listHeight === void 0 ? void 0 : {
555
- "--search-select-list-max-height": `${listHeight}px`
556
- },
557
- onScroll: (event) => {
558
- onPopupScroll?.(event);
559
- onScroll(event);
560
- },
561
- children: [
562
- grouped.map((group) => {
563
- const rows = group.items.map(({ option, index }) => /* @__PURE__ */ jsxs(
564
- "div",
565
- {
566
- id: optionDomId(option.value),
567
- role: "option",
568
- "data-testid": optionTestId(option.value),
569
- "aria-selected": isSelected(option.value),
570
- "aria-disabled": option.disabled || atMaxCount && !isSelected(option.value) || void 0,
571
- "data-disabled": option.disabled || atMaxCount && !isSelected(option.value) || void 0,
572
- className: cn(
573
- "ui-command-item",
574
- // Selected = persistent bg-accent + medium weight (NO check icon — saves width),
575
- // matching the plain SelectItem's `data-[state=checked]` convention; active =
576
- // hover/keyboard accent. Same bg so selection stays coherent across both Selects.
577
- isSelected(option.value) && "bg-accent text-foreground font-medium",
578
- activeIndex === index && "bg-accent text-accent-foreground"
579
- ),
580
- onMouseEnter: () => !option.disabled && setActiveIndex(index),
581
- onMouseDown: (event) => event.preventDefault(),
582
- onClick: () => select(option),
583
- children: [
584
- optionRender ? /* @__PURE__ */ jsx("div", { className: "ui-search-select-option-slot", children: optionRender(option, { index }) }) : renderOption ? /* @__PURE__ */ jsx("div", { className: "ui-search-select-option-slot", children: renderOption(option) }) : /* @__PURE__ */ jsxs("div", { className: "ui-search-select-option-body", children: [
585
- option.icon ? /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", "aria-hidden": "true", children: option.icon }) : null,
586
- /* @__PURE__ */ jsxs("div", { className: "ui-search-select-option-text", children: [
587
- /* @__PURE__ */ jsx("span", { className: "ui-search-select-option-label", children: option.label }),
588
- option.sublabel ? /* @__PURE__ */ jsx("span", { className: "ui-search-select-option-sublabel", children: option.sublabel }) : null
589
- ] })
590
- ] }),
591
- menuItemSelectedIcon && isSelected(option.value) ? /* @__PURE__ */ jsx(
592
- "span",
593
- {
594
- "data-slot": "search-select-selected-icon",
595
- className: "ui-search-select-selected-icon",
596
- "aria-hidden": "true",
597
- children: menuItemSelectedIcon
598
- }
599
- ) : null
600
- ]
601
- },
602
- option.value
603
- ));
604
- return group.heading ? /* @__PURE__ */ jsx(CommandGroup, { heading: group.heading, children: rows }, group.heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rows }, "__ungrouped");
605
- }),
606
- loading ? /* @__PURE__ */ jsxs("div", { role: "status", className: "ui-search-select-status", children: [
607
- /* @__PURE__ */ jsx(Loader2, { className: "ui-search-select-spinner animate-spin", "aria-hidden": "true" }),
608
- loadingMessage ?? t("dataEntry.searchSelect.loading")
609
- ] }) : error ? renderError ? renderError({
610
- message: errorMessage ?? t("dataEntry.searchSelect.error"),
611
- // Retry always reloads from the first page (a defined, predictable recovery —
612
- // NOT a resume of a failed page-N append, which would need to re-derive state).
613
- retry: () => void fetchPage(1, debouncedQuery, false)
614
- }) : /* @__PURE__ */ jsx(
615
- "div",
616
- {
617
- role: "option",
618
- "aria-disabled": "true",
619
- "aria-selected": false,
620
- className: "ui-search-select-placeholder",
621
- "data-tone": "destructive",
622
- children: errorMessage ?? t("dataEntry.searchSelect.error")
623
- }
624
- ) : displayOptions.length === 0 ? /* @__PURE__ */ jsx(
625
- "div",
626
- {
627
- role: "option",
628
- "aria-disabled": "true",
629
- "aria-selected": false,
630
- className: "ui-search-select-placeholder",
631
- children: notFoundContent ?? emptyMessage ?? t("dataEntry.searchSelect.empty")
632
- }
633
- ) : null
634
- ]
635
- }
636
- ),
637
- renderLoadMore && hasMore ? /* @__PURE__ */ jsx(
638
- "div",
639
- {
640
- className: "ui-search-select-footer",
641
- onKeyDown: (event) => event.stopPropagation(),
642
- children: renderLoadMore({
643
- hasMore,
644
- loading,
645
- loadMore: () => {
646
- if (!loading) void fetchPage(page + 1, debouncedQuery, true);
552
+ ) }),
553
+ /* @__PURE__ */ jsxs(
554
+ "div",
555
+ {
556
+ id: listId,
557
+ role: "listbox",
558
+ "aria-multiselectable": multiple || void 0,
559
+ "aria-busy": loading,
560
+ className: "ui-search-select-list",
561
+ style: listHeight === void 0 ? void 0 : {
562
+ "--search-select-list-max-height": `${listHeight}px`
563
+ },
564
+ onScroll: (event) => {
565
+ onPopupScroll?.(event);
566
+ onScroll(event);
567
+ },
568
+ children: [
569
+ grouped.map((group) => {
570
+ const rows = group.items.map(({ option, index }) => /* @__PURE__ */ jsxs(
571
+ "div",
572
+ {
573
+ id: optionDomId(option.value),
574
+ role: "option",
575
+ "data-testid": optionTestId(option.value),
576
+ "aria-selected": isSelected(option.value),
577
+ "aria-disabled": option.disabled || atMaxCount && !isSelected(option.value) || void 0,
578
+ "data-disabled": option.disabled || atMaxCount && !isSelected(option.value) || void 0,
579
+ className: cn(
580
+ "ui-command-item",
581
+ // Selected = persistent bg-accent + medium weight (NO check icon — saves width),
582
+ // matching the plain SelectItem's `data-[state=checked]` convention; active =
583
+ // hover/keyboard accent. Same bg so selection stays coherent across both Selects.
584
+ isSelected(option.value) && "bg-accent text-foreground font-medium",
585
+ activeIndex === index && "bg-accent text-accent-foreground"
586
+ ),
587
+ onMouseEnter: () => !option.disabled && setActiveIndex(index),
588
+ onMouseDown: (event) => event.preventDefault(),
589
+ onClick: () => select(option),
590
+ children: [
591
+ optionRender ? /* @__PURE__ */ jsx("div", { className: "ui-search-select-option-slot", children: optionRender(option, { index }) }) : renderOption ? /* @__PURE__ */ jsx("div", { className: "ui-search-select-option-slot", children: renderOption(option) }) : /* @__PURE__ */ jsxs("div", { className: "ui-search-select-option-body", children: [
592
+ option.icon ? /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", "aria-hidden": "true", children: option.icon }) : null,
593
+ /* @__PURE__ */ jsxs("div", { className: "ui-search-select-option-text", children: [
594
+ /* @__PURE__ */ jsx("span", { className: "ui-search-select-option-label", children: option.label }),
595
+ option.sublabel ? /* @__PURE__ */ jsx("span", { className: "ui-search-select-option-sublabel", children: option.sublabel }) : null
596
+ ] })
597
+ ] }),
598
+ menuItemSelectedIcon && isSelected(option.value) ? /* @__PURE__ */ jsx(
599
+ "span",
600
+ {
601
+ "data-slot": "search-select-selected-icon",
602
+ className: "ui-search-select-selected-icon",
603
+ "aria-hidden": "true",
604
+ children: menuItemSelectedIcon
605
+ }
606
+ ) : null
607
+ ]
608
+ },
609
+ option.value
610
+ ));
611
+ return group.heading ? /* @__PURE__ */ jsx(CommandGroup, { heading: group.heading, children: rows }, group.heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rows }, "__ungrouped");
612
+ }),
613
+ loading ? /* @__PURE__ */ jsxs("div", { role: "status", className: "ui-search-select-status", children: [
614
+ /* @__PURE__ */ jsx(Loader2, { className: "ui-search-select-spinner animate-spin", "aria-hidden": "true" }),
615
+ loadingMessage ?? t("dataEntry.searchSelect.loading")
616
+ ] }) : error ? renderError ? renderError({
617
+ message: errorMessage ?? t("dataEntry.searchSelect.error"),
618
+ // Retry always reloads from the first page (a defined, predictable recovery —
619
+ // NOT a resume of a failed page-N append, which would need to re-derive state).
620
+ retry: () => void fetchPage(1, debouncedQuery, false)
621
+ }) : /* @__PURE__ */ jsx(
622
+ "div",
623
+ {
624
+ role: "option",
625
+ "aria-disabled": "true",
626
+ "aria-selected": false,
627
+ className: "ui-search-select-placeholder",
628
+ "data-tone": "destructive",
629
+ children: errorMessage ?? t("dataEntry.searchSelect.error")
630
+ }
631
+ ) : displayOptions.length === 0 ? /* @__PURE__ */ jsx(
632
+ "div",
633
+ {
634
+ role: "option",
635
+ "aria-disabled": "true",
636
+ "aria-selected": false,
637
+ className: "ui-search-select-placeholder",
638
+ children: notFoundContent ?? emptyMessage ?? t("dataEntry.searchSelect.empty")
639
+ }
640
+ ) : null
641
+ ]
647
642
  }
648
- })
649
- }
650
- ) : null
651
- ] }) })
643
+ ),
644
+ renderLoadMore && hasMore ? /* @__PURE__ */ jsx(
645
+ "div",
646
+ {
647
+ className: "ui-search-select-footer",
648
+ onKeyDown: (event) => event.stopPropagation(),
649
+ children: renderLoadMore({
650
+ hasMore,
651
+ loading,
652
+ loadMore: () => {
653
+ if (!loading) void fetchPage(page + 1, debouncedQuery, true);
654
+ }
655
+ })
656
+ }
657
+ ) : null
658
+ ]
659
+ }
660
+ ) })
652
661
  }
653
662
  )
654
663
  ]
@@ -55,6 +55,35 @@ function itemTextValue(node) {
55
55
  }
56
56
  return "";
57
57
  }
58
+ function useSettledDefaultOpen(rootRef, defaultOpen, open) {
59
+ const [deferred] = React.useState(() => defaultOpen === true && open === void 0);
60
+ const [settledOpen, setSettledOpen] = React.useState(false);
61
+ React.useLayoutEffect(() => {
62
+ if (!deferred) return;
63
+ let cancelled = false;
64
+ const moving = [];
65
+ for (let node = rootRef.current?.parentElement; node; node = node.parentElement) {
66
+ if (typeof node.getAnimations !== "function") break;
67
+ for (const animation of node.getAnimations()) {
68
+ const end = animation.effect?.getComputedTiming().endTime;
69
+ if (animation.playState === "running" && Number.isFinite(Number(end))) {
70
+ moving.push(animation.finished);
71
+ }
72
+ }
73
+ }
74
+ if (moving.length === 0) {
75
+ setSettledOpen(true);
76
+ return;
77
+ }
78
+ void Promise.allSettled(moving).then(() => {
79
+ if (!cancelled) setSettledOpen(true);
80
+ });
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [deferred, rootRef]);
85
+ return deferred ? { open: settledOpen, setOpen: setSettledOpen } : null;
86
+ }
58
87
  function SelectRoot({
59
88
  id,
60
89
  value,
@@ -76,9 +105,12 @@ function SelectRoot({
76
105
  children
77
106
  }) {
78
107
  void dir;
108
+ const rootRef = React.useRef(null);
109
+ const settled = useSettledDefaultOpen(rootRef, defaultOpen, open);
79
110
  return /* @__PURE__ */ jsx(
80
111
  AriaSelect,
81
112
  {
113
+ ref: rootRef,
82
114
  "data-slot": "select",
83
115
  className: "ui-select-root",
84
116
  id,
@@ -87,9 +119,12 @@ function SelectRoot({
87
119
  onChange: (key) => {
88
120
  if (key != null) onValueChange?.(String(key));
89
121
  },
90
- isOpen: open,
91
- defaultOpen,
92
- onOpenChange,
122
+ isOpen: open ?? settled?.open,
123
+ defaultOpen: settled ? void 0 : defaultOpen,
124
+ onOpenChange: (next) => {
125
+ settled?.setOpen(next);
126
+ onOpenChange?.(next);
127
+ },
93
128
  isDisabled: disabled || isDisabled,
94
129
  isRequired: required,
95
130
  name,
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ export type { IconGlyphProp, IconGlyphProp as IconProps, } from "../../props/components/general.prop.js";
3
+ /**
4
+ * Icon — a glyph on the `--icon-size-*` scale, and the only supported way to draw a standalone one.
5
+ *
6
+ * WHY THIS EXISTS (gh#712). A lucide component ships `width="24" height="24"`, and exactly four
7
+ * rules in this library ever override that: `.ui-button svg`, `.ui-dropdown-menu-item > svg`,
8
+ * `.ui-topbar-item > svg` and `[data-slot="list-row-leading"] > svg`. Anywhere else — in a `Text`,
9
+ * in a table cell, in an `<a>` — the glyph draws at 24px beside 14px type, 1.71× the text it
10
+ * annotates. A consumer cannot fix that themselves: `size-4` and `w-[16px]` are what
11
+ * docs/CONSUMER-RULES.md §3/§8 forbid, and `size={16}` hard-codes a number the theme owns. One
12
+ * consumer measured 38 such glyphs in a single app.
13
+ *
14
+ * RENDERS ONTO THE GLYPH, NOT AROUND IT. `as={Lock}` is invoked with the class, the size and the
15
+ * a11y wiring, so the element that carries the metric IS the `<svg>`. A wrapper `<span>` would add
16
+ * a box to every flex row that holds an icon, and would break `Button`'s own `svg` rule by putting
17
+ * an element between the two.
18
+ *
19
+ * DECORATIVE BY DEFAULT. Without `label` the glyph is `aria-hidden` — a glyph beside a visible
20
+ * label is decoration, and announcing it twice is the usual defect (WCAG 2.2 SC 1.1.1). Pass
21
+ * `label` only when the glyph is the ONLY thing saying what it says; it then renders as
22
+ * `role="img"` + that name, which is how a graphic gets a name in WAI-ARIA 1.2. The string is the
23
+ * consumer's to localize — the library ships no copy for it.
24
+ */
25
+ export declare const Icon: React.ForwardRefExoticComponent<Omit<React.SVGProps<SVGSVGElement>, "children" | "ref"> & {
26
+ as: React.ComponentType<React.SVGProps<SVGSVGElement> & React.RefAttributes<SVGSVGElement>>;
27
+ size?: import("../../props/index.js").IconSizeProp;
28
+ tone?: import("../../props/index.js").TextToneProp;
29
+ label?: string;
30
+ className?: import("../../props/index.js").ClassNameProp;
31
+ } & React.RefAttributes<SVGSVGElement>>;
@@ -0,0 +1,23 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../../lib/utils.js";
4
+ const Icon = React.forwardRef(function Icon2({ as: Glyph, size = "md", tone, label, className, ...props }, ref) {
5
+ const named = label !== void 0 && label !== "";
6
+ return /* @__PURE__ */ jsx(
7
+ Glyph,
8
+ {
9
+ ref,
10
+ "data-slot": "icon",
11
+ "data-size": size,
12
+ "data-tone": tone,
13
+ className: cn("ui-icon", className),
14
+ role: named ? "img" : void 0,
15
+ "aria-label": named ? label : void 0,
16
+ "aria-hidden": named ? void 0 : true,
17
+ ...props
18
+ }
19
+ );
20
+ });
21
+ export {
22
+ Icon
23
+ };
@@ -2,6 +2,8 @@ export { Button, buttonVariants } from "./button.js";
2
2
  export type { ButtonProps } from "./button.js";
3
3
  export { Text, Heading, Typography, Title, Paragraph, Link } from "./typography.js";
4
4
  export type { TextProps, HeadingProps, TextProp, HeadingProp, TypographyProp, TypographyProps, TypographyBlockProp, TypographyTitleProp, TitleProps, ParagraphProp, ParagraphProps, LinkProp, LinkProps, } from "./typography.js";
5
+ export { Icon } from "./icon.js";
6
+ export type { IconGlyphProp, IconProps } from "./icon.js";
5
7
  export { Logo } from "./logo.js";
6
8
  export type { LogoMark, LogoProps, LogoSize, LogoTone } from "./logo.js";
7
9
  export { Reveal } from "./reveal.js";
@@ -1,5 +1,6 @@
1
1
  import { Button, buttonVariants } from "./button.js";
2
2
  import { Text, Heading, Typography, Title, Paragraph, Link } from "./typography.js";
3
+ import { Icon } from "./icon.js";
3
4
  import { Logo } from "./logo.js";
4
5
  import { Reveal } from "./reveal.js";
5
6
  import { FloatButton } from "./float-button.js";
@@ -15,6 +16,7 @@ export {
15
16
  Button,
16
17
  FloatButton,
17
18
  Heading,
19
+ Icon,
18
20
  Link,
19
21
  Logo,
20
22
  Paragraph,