@godxjp/ui 27.0.0 → 27.3.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 (68) 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-errors.d.ts +5 -0
  13. package/dist/components/data-entry/form-errors.js +6 -0
  14. package/dist/components/data-entry/form-field.js +9 -8
  15. package/dist/components/data-entry/index.d.ts +1 -0
  16. package/dist/components/data-entry/search-select.js +145 -136
  17. package/dist/components/data-entry/select.js +38 -3
  18. package/dist/components/feedback/dialog.d.ts +4 -3
  19. package/dist/components/feedback/dialog.js +46 -19
  20. package/dist/components/feedback/non-modal-layer.d.ts +16 -0
  21. package/dist/components/feedback/non-modal-layer.js +37 -0
  22. package/dist/components/feedback/sheet.d.ts +7 -4
  23. package/dist/components/feedback/sheet.js +63 -40
  24. package/dist/components/layout/org-switcher.js +1 -1
  25. package/dist/components/layout/responsive-grid.d.ts +8 -2
  26. package/dist/components/layout/responsive-grid.js +2 -0
  27. package/dist/components/query/mutation-feedback.js +2 -4
  28. package/dist/contracts/measurement.json +1 -1
  29. package/dist/form/form-field-control.d.ts +1 -1
  30. package/dist/form/form-field-control.js +32 -23
  31. package/dist/form/form-root.d.ts +1 -1
  32. package/dist/form/form-root.js +25 -6
  33. package/dist/i18n/messages/en.json +2 -1
  34. package/dist/i18n/messages/ja.json +2 -1
  35. package/dist/i18n/messages/vi.json +2 -1
  36. package/dist/props/components/data-display.prop.d.ts +12 -0
  37. package/dist/props/components/data-entry.prop.d.ts +10 -0
  38. package/dist/props/components/feedback.prop.d.ts +7 -1
  39. package/dist/props/components/form.prop.d.ts +52 -3
  40. package/dist/props/components/index.d.ts +1 -1
  41. package/dist/props/components/layout.prop.d.ts +7 -0
  42. package/dist/props/registry.d.ts +26 -3
  43. package/dist/props/registry.js +25 -2
  44. package/dist/props/vocabulary/data.prop.d.ts +27 -1
  45. package/dist/props/vocabulary/index.d.ts +1 -1
  46. package/dist/styles/control.css +44 -1
  47. package/dist/styles/data-display-layout.css +58 -1
  48. package/dist/styles/form-layout.css +21 -0
  49. package/dist/styles/layout.css +7 -0
  50. package/dist/styles/navigation-layout.css +5 -1
  51. package/dist/styles/shell-layout.css +1 -14
  52. package/dist/styles/table-layout.css +80 -8
  53. package/dist/tokens/components/control.css +5 -0
  54. package/dist/tokens/components/data-display.css +6 -0
  55. package/dist/tokens/components/navigation.css +1 -1
  56. package/dist/tokens/components/table.css +2 -0
  57. package/docs/DESIGN-AUTHORITY.md +32 -1
  58. package/docs/FORMS.md +11 -10
  59. package/docs/data-display/data-table/examples/server-paged.tsx +156 -0
  60. package/docs/data-display/data-table/examples/striped-list.tsx +173 -0
  61. package/docs/data-display/popover.tsx +43 -1
  62. package/docs/data-display/timeline.tsx +73 -9
  63. package/docs/data-entry/command.tsx +80 -1
  64. package/docs/data-entry/form-root.tsx +12 -2
  65. package/docs/feedback/dialog.tsx +82 -0
  66. package/docs/feedback/sheet.tsx +84 -0
  67. package/docs/layout/responsive-grid.tsx +100 -0
  68. package/package.json +4 -2
@@ -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) => (
@@ -17,6 +17,11 @@ interface FormErrorsRegistryValue {
17
17
  export declare function useFormErrorsRegistry(): FormErrorsRegistryValue | null;
18
18
  /** First message of a bag entry — Laravel arrays surface their first message on the field. */
19
19
  export declare function firstBagMessage(entry: string | string[] | undefined): string | undefined;
20
+ /**
21
+ * `true` when an error bag holds at least one non-empty message. A bag with no keys, or keys whose
22
+ * entries are empty (`{ code: [] }`), shows nothing anywhere — so it never counts as "displayed".
23
+ */
24
+ export declare function errorBagHasMessage(errors: ErrorBagProp | undefined): boolean;
20
25
  /**
21
26
  * FormErrorsProvider — one shared error registry (error bag + claim set) over a region. 兄弟 Form 群で
22
27
  * 1 つのエラーバッグを共有するための公開プロバイダ。Two ways to get one: 1. `Form errors={…}` renders this provider itself
@@ -12,6 +12,11 @@ function firstBagMessage(entry) {
12
12
  const message = Array.isArray(entry) ? entry[0] : entry;
13
13
  return message ? message : void 0;
14
14
  }
15
+ function errorBagHasMessage(errors) {
16
+ return Object.values(errors ?? {}).some(
17
+ (entry) => Array.isArray(entry) ? entry.some(Boolean) : Boolean(entry)
18
+ );
19
+ }
15
20
  function FormErrorsProvider({ errors, children }) {
16
21
  const [claimed, setClaimed] = React.useState(/* @__PURE__ */ new Map());
17
22
  const claim = React.useCallback((key) => {
@@ -65,6 +70,7 @@ function FormErrors({ errors: errorsProp, title, className }) {
65
70
  export {
66
71
  FormErrors,
67
72
  FormErrorsProvider,
73
+ errorBagHasMessage,
68
74
  firstBagMessage,
69
75
  useClaimErrorKey,
70
76
  useFormErrorsRegistry
@@ -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,
@@ -12,14 +12,15 @@ interface OverlayOpenProps {
12
12
  }
13
13
  interface DialogRootProps extends OverlayOpenProps, Pick<DialogProp, "variant"> {
14
14
  /**
15
- * Giữ tên prop của Radix. RAC không có kiểu overlay không-modal: `Modal` LUÔN khoá cuộn và ẩn
16
- * nền khỏi trình đọc màn hình, nên `modal={false}` không còn tắt được điều đó.
15
+ * `false` = hộp thoại không-modal (WAI-ARIA APG cho phép): trang phía sau vẫn tương tác được và
16
+ * vẫn trong cây trợ năng, không khoá cuộn, không màn nền, bấm ra ngoài không đóng; tiêu điểm vào
17
+ * hộp thoại khi mở, Tab ra được, Esc (tiêu điểm ở trong) đóng. Xem đầu tệp, mục gh#696.
17
18
  */
18
19
  modal?: boolean;
19
20
  children?: React.ReactNode;
20
21
  }
21
22
  /** `data-slot="dialog"` cũ nằm trên Root của Radix, thứ không dựng thẻ nào — nó chưa từng ra DOM. */
22
- declare function DialogRoot({ children, modal: _modal, variant, ...openProps }: DialogRootProps): React.JSX.Element;
23
+ declare function DialogRoot({ children, modal, variant, ...openProps }: DialogRootProps): React.JSX.Element;
23
24
  interface DialogTriggerProps extends React.ComponentPropsWithRef<"button"> {
24
25
  /** Mượn thẻ của con thay vì dựng `<button>` riêng. */
25
26
  asChild?: boolean;