@parallelworks/ui 0.53.0 → 0.55.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 (55) hide show
  1. package/README.md +68 -9
  2. package/dist/access/AccessDrawer.d.ts +55 -0
  3. package/dist/access/AccessDrawer.js +445 -0
  4. package/dist/access/AccessList.d.ts +58 -0
  5. package/dist/access/AccessList.js +382 -0
  6. package/dist/access/GrantView.d.ts +35 -0
  7. package/dist/access/GrantView.js +188 -0
  8. package/dist/access/GroupPicker.d.ts +35 -0
  9. package/dist/access/GroupPicker.js +336 -0
  10. package/dist/access/focus.d.ts +10 -0
  11. package/dist/access/focus.js +27 -0
  12. package/dist/access/index.d.ts +3 -0
  13. package/dist/access/index.js +3 -0
  14. package/dist/access/model.d.ts +49 -0
  15. package/dist/access/model.js +129 -0
  16. package/dist/access/strings.d.ts +122 -0
  17. package/dist/access/strings.js +151 -0
  18. package/dist/access/types.d.ts +56 -0
  19. package/dist/ai/components/SlashMenu.js +1 -1
  20. package/dist/components/Combobox.d.ts +19 -0
  21. package/dist/components/Combobox.js +101 -50
  22. package/dist/components/DatePicker.js +4 -5
  23. package/dist/components/Drawer.d.ts +6 -1
  24. package/dist/components/Drawer.js +25 -16
  25. package/dist/components/Field.d.ts +8 -3
  26. package/dist/components/Field.js +32 -12
  27. package/dist/components/InfoTip.d.ts +3 -1
  28. package/dist/components/InfoTip.js +2 -1
  29. package/dist/components/Menu.js +4 -4
  30. package/dist/components/MenuEntries.js +1 -1
  31. package/dist/components/OptionDescription.d.ts +7 -0
  32. package/dist/components/OptionDescription.js +19 -0
  33. package/dist/components/Popover.d.ts +4 -1
  34. package/dist/components/Popover.js +2 -2
  35. package/dist/components/Provider.d.ts +7 -0
  36. package/dist/components/Provider.js +3 -0
  37. package/dist/components/Select.js +13 -11
  38. package/dist/components/comboboxItems.d.ts +10 -0
  39. package/dist/components/comboboxItems.js +8 -1
  40. package/dist/components/floating.d.ts +5 -2
  41. package/dist/components/floating.js +2 -2
  42. package/dist/editor/EditorField.js +1 -2
  43. package/dist/editor/Monaco.js +26 -2
  44. package/dist/editor/themes.d.ts +5 -0
  45. package/dist/editor/themes.js +18 -3
  46. package/dist/file-explorer/FileExplorer.js +2 -1
  47. package/dist/file-explorer/lib/paging.d.ts +1 -0
  48. package/dist/file-explorer/lib/paging.js +5 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/styles/theme.css +82 -62
  52. package/dist/styles.css +1 -1
  53. package/dist/theme/derive.d.ts +5 -3
  54. package/dist/theme/derive.js +74 -4
  55. package/package.json +6 -2
@@ -0,0 +1,122 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type UIStrings } from '../components/Provider.js';
3
+ export declare const ACCESS_STRINGS: {
4
+ title: string;
5
+ /** Under the title: the resource, and how many groups have a role on it. */
6
+ summary: (resource: string, groups: number, everyone: boolean) => string;
7
+ /** Names the switch between the two ways of listing who has access. */
8
+ arrange: string;
9
+ byRole: string;
10
+ byGroup: string;
11
+ filter: string;
12
+ grantAccess: string;
13
+ highImpact: string;
14
+ loading: string;
15
+ groups: (n: number) => string;
16
+ /** How many of a role's groups a filter left. */
17
+ groupsShown: (shown: number, total: number) => string;
18
+ members: (n: number) => string;
19
+ people: (n: number) => string;
20
+ /** Everyone, where it is short: a tag or a picker row. */
21
+ everyone: string;
22
+ /** Everyone, where it stands as a row of its own. */
23
+ everyoneInOrganization: string;
24
+ everyoneJoinLater: string;
25
+ /** Read with the badges a group row folds away: "+2". */
26
+ moreRoles: (n: number) => string;
27
+ moreRolesLabel: (roles: readonly string[]) => string;
28
+ /** Beside a group whose other role gives everything this one does. */
29
+ coveredBy: (roles: readonly string[]) => string;
30
+ /** Beside a group that has this through everyone as well. */
31
+ everyoneHasThisToo: string;
32
+ /** A role a group holds that another of its roles already gives. */
33
+ redundant: (roles: readonly string[]) => string;
34
+ /** A role a group has only because everyone does. */
35
+ throughEveryone: string;
36
+ removeRole: (group: string, role: string) => string;
37
+ grantMoreRoles: string;
38
+ removeAllAccess: string;
39
+ removeTitle: (group: string, roles: readonly string[]) => string;
40
+ removeAllTitle: (group: string) => string;
41
+ /** Leads the list of what a group loses; `members` is undefined when the count isn't known. */
42
+ losesIntro: (members: number | undefined, everyone: boolean) => string;
43
+ /** After the list: why these are lost, and what they keep. */
44
+ keeps: (own: readonly string[], throughEveryone: readonly string[]) => string;
45
+ /** When the roles it keeps give everything the removed ones did. */
46
+ losesNothing: (own: readonly string[], throughEveryone: readonly string[]) => string;
47
+ confirmRemove: (roles: readonly string[]) => string;
48
+ removeFailed: (group: string, roles: readonly string[]) => string;
49
+ grantFailed: (groups: readonly string[], roles: readonly string[]) => string;
50
+ undoFailed: string;
51
+ /** Under a failed save; `reason` is the host's error message, when it gave one. */
52
+ nothingChanged: (reason: string | undefined) => string;
53
+ /** The toast after a grant. */
54
+ granted: (groups: readonly string[], roles: readonly string[]) => string;
55
+ /** The toast after a removal. */
56
+ removed: (groups: readonly string[], roles: readonly string[]) => string;
57
+ undone: string;
58
+ readOnly: string;
59
+ /** After `readOnly` when the host gives no reason. */
60
+ readOnlyReason: string;
61
+ emptyTitle: string;
62
+ emptyDescription: (resource: string) => string;
63
+ noMatches: string;
64
+ clearFilter: string;
65
+ back: string;
66
+ groupsLabel: string;
67
+ addGroup: string;
68
+ /** Under the groups field; `count` is undefined when the host doesn't say how many there are. */
69
+ groupsHint: (count: number | undefined, everyone: boolean) => string;
70
+ everyoneCoversGroups: string;
71
+ organization: string;
72
+ recent: string;
73
+ allGroups: string;
74
+ /** On a group's row in the picker: the roles it holds here. */
75
+ hasRoles: (roles: readonly string[]) => string;
76
+ /** Read with a group's row: its size, where it comes from and its roles here. */
77
+ groupSummary: (members: number | undefined, everyone: boolean, source: 'synced' | 'manual' | undefined, roles: readonly string[]) => string;
78
+ rolesLabel: string;
79
+ searchRoles: string;
80
+ /** On a role's row in the picker. */
81
+ alreadyHave: (groups: readonly string[]) => string;
82
+ alreadyCovered: (roles: readonly string[]) => string;
83
+ everyoneHasIt: string;
84
+ /** The footer's account of what pressing Grant does. */
85
+ grantSummary: (groups: readonly string[], roles: readonly string[]) => ReactNode;
86
+ /** Why Grant can't be pressed: every group has every role already. */
87
+ nothingToGrant: (groups: readonly string[], roles: readonly string[]) => string;
88
+ pickGroupsAndRoles: string;
89
+ pickGroups: string;
90
+ pickRoles: string;
91
+ grant: (roles: readonly string[]) => string;
92
+ grantToEveryone: string;
93
+ everyoneWarningTitle: (roles: readonly string[], people: number | undefined) => string;
94
+ everyoneWarning: (resource: string) => string;
95
+ confirmEveryoneTitle: (roles: readonly string[], people: number | undefined) => string;
96
+ synced: string;
97
+ syncedShort: string;
98
+ manual: string;
99
+ wholeOrganization: string;
100
+ joinLater: string;
101
+ /** The first few members by name, and how many more there are. */
102
+ memberNames: (names: readonly string[], more: number) => string;
103
+ moreMembers: (n: number) => string;
104
+ onResource: (resource: string) => string;
105
+ noRolesHere: string;
106
+ /** When a role being granted gives everything one the group holds does. */
107
+ becomesRedundant: (granting: readonly string[], held: readonly string[]) => string;
108
+ alreadyHas: (roles: readonly string[]) => string;
109
+ everyoneReach: (roles: readonly string[], people: number | undefined) => string;
110
+ updated: (ago: string) => string;
111
+ detailsFailed: string;
112
+ loadingDetails: string;
113
+ noHighlight: string;
114
+ };
115
+ /** The drawer's own text, which a host translates through UIProvider's `access`. */
116
+ export type AccessText = typeof ACCESS_STRINGS;
117
+ /** The drawer's text with the shared words, such as Cancel and Retry, beside it. */
118
+ export type AccessStrings = AccessText & {
119
+ common: UIStrings['common'];
120
+ };
121
+ /** The access drawer's strings: the host's translations over the English defaults. */
122
+ export declare function useAccessStrings(): AccessStrings;
@@ -0,0 +1,151 @@
1
+ import { useStrings } from "../components/Provider.js";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useMemo } from "react";
4
+ //#region src/access/strings.tsx
5
+ var plural = new Intl.PluralRules("en");
6
+ var list = new Intl.ListFormat("en", { type: "conjunction" });
7
+ /** A count and its noun: "1 group", "1,024 groups". */
8
+ var countOf = (n, one, other) => `${n.toLocaleString()} ${plural.select(n) === "one" ? one : other}`;
9
+ var and = (names) => list.format(names);
10
+ var has = (names) => names.length === 1 ? "has" : "have";
11
+ var ACCESS_STRINGS = {
12
+ title: "Access",
13
+ /** Under the title: the resource, and how many groups have a role on it. */
14
+ summary: (resource, groups, everyone) => everyone ? groups === 0 ? `${resource} · Everyone has access` : `${resource} · Everyone and ${countOf(groups, "group have access", "groups have access")}` : groups === 0 ? `${resource} · No groups have access` : `${resource} · ${countOf(groups, "group has access", "groups have access")}`,
15
+ /** Names the switch between the two ways of listing who has access. */
16
+ arrange: "List access",
17
+ byRole: "By role",
18
+ byGroup: "By group",
19
+ filter: "Filter by role or group",
20
+ grantAccess: "Grant access",
21
+ highImpact: "High impact",
22
+ loading: "Loading who has access",
23
+ groups: (n) => countOf(n, "group", "groups"),
24
+ /** How many of a role's groups a filter left. */
25
+ groupsShown: (shown, total) => `${shown.toLocaleString()} of ${countOf(total, "group", "groups")}`,
26
+ members: (n) => countOf(n, "member", "members"),
27
+ people: (n) => countOf(n, "person", "people"),
28
+ /** Everyone, where it is short: a tag or a picker row. */
29
+ everyone: "Everyone",
30
+ /** Everyone, where it stands as a row of its own. */
31
+ everyoneInOrganization: "Everyone in your organization",
32
+ everyoneJoinLater: "Including people who join later",
33
+ /** Read with the badges a group row folds away: "+2". */
34
+ moreRoles: (n) => `+${n.toLocaleString()}`,
35
+ moreRolesLabel: (roles) => `Also ${and(roles)}`,
36
+ /** Beside a group whose other role gives everything this one does. */
37
+ coveredBy: (roles) => `${and(roles)} covers this too`,
38
+ /** Beside a group that has this through everyone as well. */
39
+ everyoneHasThisToo: "Everyone has this too",
40
+ /** A role a group holds that another of its roles already gives. */
41
+ redundant: (roles) => `Redundant: ${and(roles)} already covers this.`,
42
+ /** A role a group has only because everyone does. */
43
+ throughEveryone: "Everyone in your organization has it",
44
+ removeRole: (group, role) => `Remove ${group} from ${role}`,
45
+ grantMoreRoles: "Grant more roles",
46
+ removeAllAccess: "Remove all access",
47
+ removeTitle: (group, roles) => `Remove ${group} from ${and(roles)}?`,
48
+ removeAllTitle: (group) => `Remove all of ${group}’s access?`,
49
+ /** Leads the list of what a group loses; `members` is undefined when the count isn't known. */
50
+ losesIntro: (members, everyone) => everyone ? "People who have it only through everyone will no longer be able to:" : members === void 0 ? "Its members will no longer be able to:" : `Its ${countOf(members, "member", "members")} will no longer be able to:`,
51
+ /** After the list: why these are lost, and what they keep. */
52
+ keeps: (own, throughEveryone) => [
53
+ "No other role of theirs gives these.",
54
+ own.length > 0 && `They keep ${and(own)}.`,
55
+ throughEveryone.length > 0 && `They keep ${and(throughEveryone)}, which everyone has.`
56
+ ].filter(Boolean).join(" "),
57
+ /** When the roles it keeps give everything the removed ones did. */
58
+ losesNothing: (own, throughEveryone) => `They lose nothing: ${and([...own, ...throughEveryone])} still ${own.length + throughEveryone.length === 1 ? "gives" : "give"} them all of it.`,
59
+ confirmRemove: (roles) => roles.length === 1 ? `Remove ${roles[0]}` : "Remove all access",
60
+ removeFailed: (group, roles) => `Couldn’t remove ${group} from ${and(roles)}`,
61
+ grantFailed: (groups, roles) => `Couldn’t grant ${and(roles)} to ${and(groups)}`,
62
+ undoFailed: "Couldn’t undo the change",
63
+ /** Under a failed save; `reason` is the host's error message, when it gave one. */
64
+ nothingChanged: (reason) => reason ? `${reason.replace(/\.?$/, ".")} Nothing changed.` : "Nothing changed.",
65
+ /** The toast after a grant. */
66
+ granted: (groups, roles) => `Gave ${and(groups)} ${and(roles)}.`,
67
+ /** The toast after a removal. */
68
+ removed: (groups, roles) => `Removed ${and(groups)} from ${and(roles)}.`,
69
+ undone: "Change undone.",
70
+ readOnly: "You can see who has access.",
71
+ /** After `readOnly` when the host gives no reason. */
72
+ readOnlyReason: "Only people who manage access here can change it.",
73
+ emptyTitle: "No groups have access yet",
74
+ emptyDescription: (resource) => `Give a group a role so its members can see, use or manage ${resource}.`,
75
+ noMatches: "No roles or groups match",
76
+ clearFilter: "Clear filter",
77
+ back: "Back to who has access",
78
+ groupsLabel: "Groups",
79
+ addGroup: "Add a group",
80
+ /** Under the groups field; `count` is undefined when the host doesn't say how many there are. */
81
+ groupsHint: (count, everyone) => count === void 0 ? everyone ? "Search groups, or pick Everyone in your organization." : "Search groups." : `Search ${countOf(count, "group", "groups")}${everyone ? ", or pick Everyone in your organization" : ""}.`,
82
+ everyoneCoversGroups: "Everyone covers every group, so there’s no need to add others.",
83
+ organization: "Organization",
84
+ recent: "Recent",
85
+ allGroups: "All groups",
86
+ /** On a group's row in the picker: the roles it holds here. */
87
+ hasRoles: (roles) => `has ${and(roles)}`,
88
+ /** Read with a group's row: its size, where it comes from and its roles here. */
89
+ groupSummary: (members, everyone, source, roles) => [
90
+ members !== void 0 && (everyone ? countOf(members, "person", "people") : countOf(members, "member", "members")),
91
+ source === "synced" && "synced",
92
+ roles.length > 0 ? `has ${and(roles)} here` : "no roles here"
93
+ ].filter(Boolean).join(", "),
94
+ rolesLabel: "Roles",
95
+ searchRoles: "Search roles",
96
+ /** On a role's row in the picker. */
97
+ alreadyHave: (groups) => `${and(groups)} already ${has(groups)} it.`,
98
+ alreadyCovered: (roles) => `${and(roles)} already covers this.`,
99
+ everyoneHasIt: "Everyone already has it.",
100
+ /** The footer's account of what pressing Grant does. */
101
+ grantSummary: (groups, roles) => /* @__PURE__ */ jsxs(Fragment, { children: [
102
+ /* @__PURE__ */ jsx("strong", {
103
+ className: "font-medium text-(--theme-app)",
104
+ children: and(groups)
105
+ }),
106
+ " ",
107
+ groups.length === 1 ? "gets" : "get",
108
+ " ",
109
+ and(roles),
110
+ "."
111
+ ] }),
112
+ /** Why Grant can't be pressed: every group has every role already. */
113
+ nothingToGrant: (groups, roles) => `${and(groups)} already ${has(groups)} ${and(roles)}, so there’s nothing to grant.`,
114
+ pickGroupsAndRoles: "Pick the groups and the roles to grant.",
115
+ pickGroups: "Pick a group to grant to.",
116
+ pickRoles: "Pick a role to grant.",
117
+ grant: (roles) => roles.length === 0 ? "Grant" : roles.length === 1 ? `Grant ${roles[0]}` : `Grant ${countOf(roles.length, "role", "roles")}`,
118
+ grantToEveryone: "Grant to everyone",
119
+ everyoneWarningTitle: (roles, people) => people === void 0 ? `${and(roles)} for everyone` : `${and(roles)} for all ${countOf(people, "person", "people")}`,
120
+ everyoneWarning: (resource) => `Everyone in your organization, and everyone who joins later, will have it on ${resource}.`,
121
+ confirmEveryoneTitle: (roles, people) => people === void 0 ? `Grant ${and(roles)} to everyone?` : `Grant ${and(roles)} to all ${countOf(people, "person", "people")}?`,
122
+ synced: "Synced from your identity provider",
123
+ syncedShort: "Synced",
124
+ manual: "Members added by hand",
125
+ wholeOrganization: "Your whole organization",
126
+ joinLater: "And anyone who joins later gets it automatically.",
127
+ /** The first few members by name, and how many more there are. */
128
+ memberNames: (names, more) => more > 0 ? and([...names, `${more.toLocaleString()} more`]) : and(names),
129
+ moreMembers: (n) => `+${n.toLocaleString()}`,
130
+ onResource: (resource) => `On ${resource}`,
131
+ noRolesHere: "No roles here yet.",
132
+ /** When a role being granted gives everything one the group holds does. */
133
+ becomesRedundant: (granting, held) => `${and(granting)} also ${granting.length === 1 ? "gives" : "give"} them ${and(held)}, so ${and(held)} becomes redundant. Nothing is removed.`,
134
+ alreadyHas: (roles) => `Already has ${and(roles)}.`,
135
+ everyoneReach: (roles, people) => `${and(roles)} for everyone reaches ${people === void 0 ? "every person" : `all ${countOf(people, "person", "people")}`} in your organization, and anyone who joins later. You’ll be asked to confirm.`,
136
+ updated: (ago) => `Membership updated ${ago}`,
137
+ detailsFailed: "Couldn’t load this group’s details.",
138
+ loadingDetails: "Loading the group’s details",
139
+ noHighlight: "Highlight a group to see who is in it."
140
+ };
141
+ /** The access drawer's strings: the host's translations over the English defaults. */
142
+ function useAccessStrings() {
143
+ const { access: overrides, common } = useStrings();
144
+ return useMemo(() => ({
145
+ ...ACCESS_STRINGS,
146
+ ...overrides,
147
+ common
148
+ }), [overrides, common]);
149
+ }
150
+ //#endregion
151
+ export { ACCESS_STRINGS, useAccessStrings };
@@ -0,0 +1,56 @@
1
+ import type { TimeValue } from '../format.js';
2
+ /** A named set of permissions. Groups are granted roles, never permissions one by one. */
3
+ export interface AccessRole {
4
+ id: string;
5
+ name: string;
6
+ /** What it lets someone do, in a sentence. */
7
+ description?: string | undefined;
8
+ /** The permissions it gives. Two roles that share some are allowed, and only redundant: the
9
+ * drawer works out the overlap from these and says so as a hint. */
10
+ permissions: readonly string[];
11
+ /** Marks a role that reaches far, such as root on every node. It carries a tag, and granting
12
+ * it to everyone says so before asking to confirm. */
13
+ highImpact?: boolean | undefined;
14
+ }
15
+ /** A permission's name in words, for what a group loses when a role is taken away. */
16
+ export interface AccessPermission {
17
+ id: string;
18
+ /** What it lets someone do, as a phrase that follows "will no longer be able to": "Sign in". */
19
+ label: string;
20
+ }
21
+ /** Who a role can be granted to. */
22
+ export interface AccessGroup {
23
+ id: string;
24
+ name: string;
25
+ /** How many people are in it; for everyone, the organization's size. */
26
+ memberCount?: number | undefined;
27
+ /** `everyone` is the whole organization, people who join later included. At most one group
28
+ * is; leave it out where a resource can't be granted to everyone. */
29
+ kind?: 'everyone' | 'group' | undefined;
30
+ }
31
+ /** A role held by a group. */
32
+ export interface AccessGrant {
33
+ groupId: string;
34
+ roleId: string;
35
+ }
36
+ /** What one change grants and takes away; neither list holds a grant that changes nothing. */
37
+ export interface AccessChange {
38
+ grant: AccessGrant[];
39
+ revoke: AccessGrant[];
40
+ }
41
+ /** A person, as a group's details show its first few members. */
42
+ export interface AccessMember {
43
+ name: string;
44
+ avatarUrl?: string | undefined;
45
+ }
46
+ /** More about a group than the list needs, loaded when it is highlighted in the group picker. */
47
+ export interface AccessGroupDetails {
48
+ description?: string | undefined;
49
+ /** `synced` comes from an identity provider; `manual` is kept by hand. */
50
+ source?: 'synced' | 'manual' | undefined;
51
+ /** The first few, in the order to show them. */
52
+ members: readonly AccessMember[];
53
+ memberCount: number;
54
+ /** When its membership last changed. */
55
+ updatedAt?: TimeValue;
56
+ }
@@ -83,7 +83,7 @@ function SlashMenu({ label, listId, matches, active, onChoose }) {
83
83
  role: "listbox",
84
84
  "aria-label": label,
85
85
  "data-testid": "slash-menu",
86
- className: cx("absolute bottom-full left-0 right-0 z-20 mb-2 overflow-hidden py-1", panelClassName),
86
+ className: cx("absolute bottom-full left-0 right-0 z-20 mb-2 overflow-hidden", "p-1", panelClassName),
87
87
  children: matches.map((c, i) => /* @__PURE__ */ jsxs("div", {
88
88
  id: optionId(listId, i),
89
89
  role: "option",
@@ -46,6 +46,25 @@ export type ComboboxProps<Value = string> = FieldProps & SelectionProps<Value> &
46
46
  trigger?: ReactElement | undefined;
47
47
  /** A hint on the trigger button. */
48
48
  triggerTooltip?: ReactNode;
49
+ /** A pane beside the list about the highlighted item, such as who is in a group; it gets
50
+ * undefined while nothing is highlighted. It follows the highlight from the keyboard and
51
+ * the pointer alike, and is hidden from assistive technology, so each item's
52
+ * `accessibleDescription` must say what matters in it. Where the list is narrower than
53
+ * 30rem the pane has no room, and `detailsSummary` shows under the highlighted item. */
54
+ details?: ((value: Value | undefined) => ReactNode) | undefined;
55
+ /** Called as the highlight moves, from the keyboard or the pointer, such as to load what
56
+ * `details` shows; undefined once nothing is highlighted. */
57
+ onItemHighlighted?: ((value: Value | undefined) => void) | undefined;
58
+ /** A line about the highlighted item, under it, where the list has no room for `details`. */
59
+ detailsSummary?: ((value: Value) => ReactNode) | undefined;
60
+ /** Called as the typed text changes. With it, the host does the filtering: the list shows
61
+ * `items` as given, such as the results of a search on the server. */
62
+ onQueryChange?: ((query: string) => void) | undefined;
63
+ /** The host's search is under way. */
64
+ loading?: boolean | undefined;
65
+ /** The area the open list stays inside, such as a drawer's body above its footer; by
66
+ * default, the viewport. With `details`, the list grows to fill it. */
67
+ collisionBoundary?: Element | null | undefined;
49
68
  };
50
69
  export interface AutocompleteProps extends FieldProps {
51
70
  /** Suggestions; any typed text is also a valid value. */
@@ -1,11 +1,13 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { AddIcon, CheckIcon, ChevronDownIcon, RemoveIcon } from "../icons.js";
3
3
  import { panelClassName, positionerClassName } from "./floating.js";
4
+ import { Loader } from "./Loader.js";
4
5
  import { Tooltip } from "./Tooltip.js";
5
6
  import { useStrings } from "./Provider.js";
6
7
  import { useQueryMatch } from "./search.js";
7
8
  import { fieldBox } from "./fieldBox.js";
8
- import { isComboboxGroup, pickerItemClassName } from "./comboboxItems.js";
9
+ import { isComboboxGroup, optionNaming, pickerItemClassName } from "./comboboxItems.js";
10
+ import { OptionDescription } from "./OptionDescription.js";
9
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
12
  import { useCallback, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
11
13
  import { dequal } from "dequal";
@@ -79,13 +81,14 @@ var triggerClassName = "flex shrink-0 items-center self-stretch px-2 text-(--the
79
81
  var popupInputClassName = fieldBox({ size: "sm" });
80
82
  /** A pick-from-the-list field: one value, or several with `multiple`. Typing filters the list. */
81
83
  function Combobox$1(props) {
82
- const { items, isItemEqualToValue = dequal, itemToStringLabel = String, className, placeholder, disabled, required, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, trigger, triggerTooltip, size = "md" } = props;
84
+ const { items, isItemEqualToValue = dequal, itemToStringLabel = String, className, placeholder, disabled, required, id, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, trigger, triggerTooltip, size = "md", details, detailsSummary, onItemHighlighted, onQueryChange, loading = false, collisionBoundary } = props;
83
85
  const entries = useEntries(items);
84
86
  const filter = useEntryFilter();
85
87
  const t = useStrings().dropdown;
86
88
  const list = useRef(null);
87
89
  const uid = useId();
88
90
  const [inputValue, setInputValue] = useState(null);
91
+ const [highlighted, setHighlighted] = useState(void 0);
89
92
  const entryOf = (value) => entries.flat.find((entry) => isItemEqualToValue(entry.value, value)) ?? {
90
93
  value,
91
94
  label: itemToStringLabel(value)
@@ -125,15 +128,21 @@ function Combobox$1(props) {
125
128
  autoComplete: "off",
126
129
  className: trigger ? popupInputClassName : inputClassName(size)
127
130
  });
131
+ const isHighlighted = (entry) => highlighted !== void 0 && !entry.created && !highlighted.created && isItemEqualToValue(entry.value, highlighted.value);
128
132
  const itemRow = (entry, rowProps) => /* @__PURE__ */ jsxs(Combobox.Item, {
129
133
  value: entry,
130
134
  disabled: entry.disabled || full && !isChosen(entry),
131
135
  ...rowProps.props,
132
- ...optionNaming(uid, rowProps.props.index, entry),
136
+ ...optionNaming(`${uid}${rowProps.props.index}`, entry),
133
137
  className: pickerItemClassName,
134
138
  children: [/* @__PURE__ */ jsx(ItemContent, {
135
139
  entry,
136
- id: `${uid}${rowProps.props.index}`
140
+ id: `${uid}${rowProps.props.index}`,
141
+ children: detailsSummary && isHighlighted(entry) && /* @__PURE__ */ jsx("span", {
142
+ "aria-hidden": "true",
143
+ className: "mt-0.5 text-xs leading-snug text-(--theme-muted-text-color) @min-[30rem]/picker:hidden",
144
+ children: detailsSummary(entry.value)
145
+ })
137
146
  }), /* @__PURE__ */ jsx(Combobox.ItemIndicator, {
138
147
  className: "ml-auto flex shrink-0 items-center",
139
148
  children: /* @__PURE__ */ jsx(CheckIcon, {
@@ -162,7 +171,10 @@ function Combobox$1(props) {
162
171
  className: "border-b border-(--theme-border) p-1.5",
163
172
  children: input
164
173
  }),
165
- className: "w-max min-w-(--anchor-width) max-w-[min(28rem,var(--available-width))]",
174
+ className: details ? "w-[min(36rem,var(--available-width))]" : "w-max min-w-(--anchor-width) max-w-[min(28rem,var(--available-width))]",
175
+ loading: loading ? t.searching : void 0,
176
+ details: details?.(highlighted?.created ? void 0 : highlighted?.value),
177
+ collisionBoundary,
166
178
  renderItem: itemRow
167
179
  })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Combobox.InputGroup, {
168
180
  "data-testid": "combobox",
@@ -198,15 +210,25 @@ function Combobox$1(props) {
198
210
  empty: t.noOptionsFound,
199
211
  statusGroups: entries.statusGroups,
200
212
  query: inputValue ?? "",
213
+ loading: loading ? t.searching : void 0,
214
+ details: details?.(highlighted?.created ? void 0 : highlighted?.value),
215
+ collisionBoundary,
201
216
  renderItem: itemRow
202
217
  })] });
203
218
  const root = {
204
219
  items: listItems,
205
- filter,
206
220
  isItemEqualToValue: (a, b) => isItemEqualToValue(a.value, b.value),
207
221
  itemToStringLabel: entryLabel,
208
- onInputValueChange: setInputValue,
209
- onItemHighlighted: (_, { index, reason }) => list.current?.reveal(index, reason),
222
+ filter: onQueryChange ? null : filter,
223
+ onInputValueChange: (next) => {
224
+ setInputValue(next);
225
+ onQueryChange?.(next);
226
+ },
227
+ onItemHighlighted: (entry, { index, reason }) => {
228
+ if (details || detailsSummary) setHighlighted(entry);
229
+ onItemHighlighted?.(entry && !entry.created ? entry.value : void 0);
230
+ list.current?.reveal(index, reason);
231
+ },
210
232
  disabled,
211
233
  required,
212
234
  virtualized: true,
@@ -218,7 +240,12 @@ function Combobox$1(props) {
218
240
  multiple: true,
219
241
  value: toEntries(props.value),
220
242
  defaultValue: toEntries(props.defaultValue),
221
- onValueChange: (next) => {
243
+ onValueChange: (next, details) => {
244
+ if (details.reason === "escape-key") {
245
+ details.cancel();
246
+ details.allowPropagation();
247
+ return;
248
+ }
222
249
  setUncontrolledCount(next.length);
223
250
  props.onValueChange?.(next.map((entry) => entry.value));
224
251
  }
@@ -275,7 +302,7 @@ function Autocomplete$1({ items, value, defaultValue, onValueChange, className,
275
302
  value: entry,
276
303
  disabled: entry.disabled,
277
304
  ...rowProps.props,
278
- ...optionNaming(uid, rowProps.props.index, entry),
305
+ ...optionNaming(`${uid}${rowProps.props.index}`, entry),
279
306
  className: pickerItemClassName,
280
307
  children: /* @__PURE__ */ jsx(ItemContent, {
281
308
  entry,
@@ -285,33 +312,31 @@ function Autocomplete$1({ items, value, defaultValue, onValueChange, className,
285
312
  })]
286
313
  });
287
314
  }
288
- /** An option is named by its label, with its description read after it rather than as part of
289
- * its name. */
290
- function optionNaming(uid, index, entry) {
291
- return {
292
- "aria-labelledby": `${uid}${index}label`,
293
- "aria-describedby": entry.description ? `${uid}${index}description` : void 0
294
- };
295
- }
296
- function ItemContent({ entry, id }) {
315
+ function ItemContent({ entry, id, children }) {
297
316
  return /* @__PURE__ */ jsxs(Fragment, { children: [entry.icon && /* @__PURE__ */ jsx("span", {
298
317
  className: "flex shrink-0 items-center",
299
318
  children: entry.icon
300
319
  }), /* @__PURE__ */ jsxs("span", {
301
320
  className: "flex min-w-0 flex-col",
302
- children: [/* @__PURE__ */ jsx(Tooltip, {
303
- content: entry.label,
304
- overflowOnly: true,
305
- children: /* @__PURE__ */ jsx("span", {
306
- id: `${id}label`,
307
- className: "truncate",
308
- children: entry.label
309
- })
310
- }), entry.description && /* @__PURE__ */ jsx("span", {
311
- id: `${id}description`,
312
- className: "text-xs leading-snug text-(--theme-muted-text-color)",
313
- children: entry.description
314
- })]
321
+ children: [
322
+ /* @__PURE__ */ jsxs("span", {
323
+ className: "flex min-w-0 items-center gap-2",
324
+ children: [/* @__PURE__ */ jsx(Tooltip, {
325
+ content: entry.label,
326
+ overflowOnly: true,
327
+ children: /* @__PURE__ */ jsx("span", {
328
+ id: `${id}label`,
329
+ className: "truncate",
330
+ children: entry.label
331
+ })
332
+ }), entry.badge]
333
+ }),
334
+ /* @__PURE__ */ jsx(OptionDescription, {
335
+ id: `${id}description`,
336
+ item: entry
337
+ }),
338
+ children
339
+ ]
315
340
  })] });
316
341
  }
317
342
  function GroupHeading({ group }) {
@@ -335,26 +360,52 @@ function GroupHeading({ group }) {
335
360
  ] });
336
361
  }
337
362
  /** Portal, positioner and popup shared by Combobox and Autocomplete; both use the same parts. */
338
- function ListPopup({ listRef, empty, statusGroups = [], query = "", header, className = "w-(--anchor-width) max-w-(--available-width)", renderItem }) {
363
+ function ListPopup({ listRef, empty, statusGroups = [], query = "", header, className = "w-(--anchor-width) max-w-(--available-width)", loading, details, collisionBoundary, renderItem }) {
364
+ const withDetails = details !== void 0;
365
+ const list = /* @__PURE__ */ jsxs(Fragment, { children: [
366
+ loading && /* @__PURE__ */ jsxs("div", {
367
+ role: "status",
368
+ className: "flex items-center gap-2 px-3 py-2 text-(--theme-muted-text-color)",
369
+ children: [/* @__PURE__ */ jsx(Loader, {
370
+ inline: true,
371
+ decorative: true,
372
+ size: "1em"
373
+ }), loading]
374
+ }),
375
+ empty && !loading && statusGroups.length === 0 && /* @__PURE__ */ jsx(Combobox.Empty, {
376
+ className: "px-3 py-2 text-(--theme-muted-text-color) empty:hidden",
377
+ children: empty
378
+ }),
379
+ /* @__PURE__ */ jsx(Combobox.List, {
380
+ className: cx(withDetails && "min-h-0 flex-1"),
381
+ children: /* @__PURE__ */ jsx(VirtualList, {
382
+ ref: listRef,
383
+ statusGroups,
384
+ query,
385
+ tall: withDetails,
386
+ renderItem
387
+ })
388
+ })
389
+ ] });
339
390
  return /* @__PURE__ */ jsx(Combobox.Portal, { children: /* @__PURE__ */ jsx(Combobox.Positioner, {
340
391
  align: "start",
341
392
  sideOffset: 4,
342
393
  className: positionerClassName,
394
+ ...collisionBoundary && { collisionBoundary },
343
395
  children: /* @__PURE__ */ jsxs(Combobox.Popup, {
344
- className: cx("overflow-hidden", className, panelClassName),
345
- children: [
346
- header,
347
- empty && statusGroups.length === 0 && /* @__PURE__ */ jsx(Combobox.Empty, {
348
- className: "px-3 py-2 text-(--theme-muted-text-color) empty:hidden",
349
- children: empty
350
- }),
351
- /* @__PURE__ */ jsx(Combobox.List, { children: /* @__PURE__ */ jsx(VirtualList, {
352
- ref: listRef,
353
- statusGroups,
354
- query,
355
- renderItem
356
- }) })
357
- ]
396
+ className: cx("overflow-hidden", withDetails && "@container/picker flex h-[min(40rem,var(--available-height))] flex-col", className, panelClassName, !empty && !loading && statusGroups.length === 0 && !header && "data-empty:hidden"),
397
+ children: [header, withDetails ? /* @__PURE__ */ jsxs("div", {
398
+ className: "flex min-h-0 flex-1",
399
+ children: [/* @__PURE__ */ jsx("div", {
400
+ className: "flex min-w-0 flex-1 flex-col",
401
+ children: list
402
+ }), /* @__PURE__ */ jsxs("div", {
403
+ "aria-hidden": "true",
404
+ "data-testid": "combobox-details",
405
+ className: "hidden w-1/2 shrink-0 overflow-y-auto overscroll-contain border-l border-(--theme-border) bg-(--theme-panel-bg) @min-[30rem]/picker:block",
406
+ children: [details, /* @__PURE__ */ jsx("div", { className: "pointer-events-none sticky bottom-0 -mt-6 h-6 bg-linear-to-t from-(--theme-panel-bg) to-transparent" })]
407
+ })]
408
+ }) : list]
358
409
  })
359
410
  }) });
360
411
  }
@@ -382,7 +433,7 @@ function toRows(filtered, statusGroups) {
382
433
  rowOfItem
383
434
  };
384
435
  }
385
- function VirtualList({ ref, statusGroups, query, renderItem }) {
436
+ function VirtualList({ ref, statusGroups, query, tall, renderItem }) {
386
437
  const filtered = Combobox.useFilteredItems();
387
438
  const match = useQueryMatch();
388
439
  const { rows, rowOfItem } = useMemo(() => {
@@ -420,7 +471,7 @@ function VirtualList({ ref, statusGroups, query, renderItem }) {
420
471
  return /* @__PURE__ */ jsx("div", {
421
472
  role: "presentation",
422
473
  ref: scrollerRef,
423
- className: "max-h-[min(15rem,var(--available-height))] overflow-auto overscroll-contain",
474
+ className: cx("overflow-auto overscroll-contain px-1", tall ? "max-h-full" : "max-h-[min(15rem,var(--available-height))]"),
424
475
  style: { height: total },
425
476
  children: /* @__PURE__ */ jsx("div", {
426
477
  role: "presentation",
@@ -441,7 +492,7 @@ function VirtualList({ ref, statusGroups, query, renderItem }) {
441
492
  "data-index": virtual.index,
442
493
  ref: virtualizer.measureElement,
443
494
  style,
444
- className: "flex items-center gap-1.5 px-3 pt-2 pb-1 text-xs text-(--theme-muted-text-color)",
495
+ className: "flex items-center gap-1.5 px-2 pt-2 pb-1 text-xs text-(--theme-muted-text-color)",
445
496
  children: /* @__PURE__ */ jsx(GroupHeading, { group: row.group })
446
497
  }, virtual.key);
447
498
  return renderItem(row.entry, {