untitledui 0.1.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 (121) hide show
  1. package/dist/commands/add.js +339 -0
  2. package/dist/commands/init.js +436 -0
  3. package/dist/helper/download-tar-api.js +129 -0
  4. package/dist/helper/download-tar.js +81 -0
  5. package/dist/helper/find-css-file.js +19 -0
  6. package/dist/helper/formatText.js +37 -0
  7. package/dist/helper/get-components-api.js +47 -0
  8. package/dist/helper/get-components-list.js +62 -0
  9. package/dist/helper/get-components.js +19 -0
  10. package/dist/helper/get-config.js +163 -0
  11. package/dist/helper/get-package-info.js +99 -0
  12. package/dist/helper/get-pkg-manager.js +16 -0
  13. package/dist/helper/get-project.js +176 -0
  14. package/dist/helper/install-template.js +29 -0
  15. package/dist/helper/match-color-css.js +82 -0
  16. package/dist/helper/update-color-css.js +134 -0
  17. package/dist/index.js +25 -0
  18. package/dist/package.json +50 -0
  19. package/dist/res/components.json +520 -0
  20. package/dist/res/config.json +3 -0
  21. package/package.json +61 -0
  22. package/templates/default/.prettierrc +10 -0
  23. package/templates/default/README.md +36 -0
  24. package/templates/default/eslint.config.mjs +58 -0
  25. package/templates/default/next.config.ts +6 -0
  26. package/templates/default/package.json +57 -0
  27. package/templates/default/postcss.config.js +5 -0
  28. package/templates/default/public/favicon.ico +0 -0
  29. package/templates/default/public/marketing/smiling-girl.png +0 -0
  30. package/templates/default/public/marketing/spirals.webp +0 -0
  31. package/templates/default/src/app/home-screen.tsx +109 -0
  32. package/templates/default/src/app/layout.tsx +42 -0
  33. package/templates/default/src/app/not-found.tsx +40 -0
  34. package/templates/default/src/app/page.tsx +3 -0
  35. package/templates/default/src/components/foundations/dot-icon.tsx +27 -0
  36. package/templates/default/src/components/foundations/featured-icon/featured-icons.tsx +153 -0
  37. package/templates/default/src/components/foundations/logo/UntitledLogo.tsx +63 -0
  38. package/templates/default/src/components/foundations/logo/UntitledLogoMinimal.tsx +164 -0
  39. package/templates/default/src/components/foundations/payment-icons/amex-icon.tsx +19 -0
  40. package/templates/default/src/components/foundations/payment-icons/apple-pay-icon.tsx +27 -0
  41. package/templates/default/src/components/foundations/payment-icons/discover-icon.tsx +34 -0
  42. package/templates/default/src/components/foundations/payment-icons/index.tsx +10 -0
  43. package/templates/default/src/components/foundations/payment-icons/mastercard-icon.tsx +39 -0
  44. package/templates/default/src/components/foundations/payment-icons/paypal-icon.tsx +45 -0
  45. package/templates/default/src/components/foundations/payment-icons/stripe-icon.tsx +27 -0
  46. package/templates/default/src/components/foundations/payment-icons/union-pay-icon.tsx +37 -0
  47. package/templates/default/src/components/foundations/payment-icons/visa-icon.tsx +27 -0
  48. package/templates/default/src/components/marketing/header-navigation/base-components/nav-menu-item.tsx +41 -0
  49. package/templates/default/src/components/marketing/header-navigation/components/header.tsx +245 -0
  50. package/templates/default/src/components/marketing/header-navigation/dropdown-header-navigation.tsx +53 -0
  51. package/templates/default/src/components/shared/avatar/avatar-label-group.tsx +32 -0
  52. package/templates/default/src/components/shared/avatar/avatar-profile-photo.tsx +84 -0
  53. package/templates/default/src/components/shared/avatar/avatar.tsx +131 -0
  54. package/templates/default/src/components/shared/avatar/base-components/avatar-add-button.tsx +33 -0
  55. package/templates/default/src/components/shared/avatar/base-components/avatar-company-icon.tsx +26 -0
  56. package/templates/default/src/components/shared/avatar/base-components/avatar-online-indicator.tsx +31 -0
  57. package/templates/default/src/components/shared/avatar/base-components/index.ts +4 -0
  58. package/templates/default/src/components/shared/avatar/base-components/verified-tick.tsx +34 -0
  59. package/templates/default/src/components/shared/avatar/utils.ts +12 -0
  60. package/templates/default/src/components/shared/badges/badge-groups.tsx +176 -0
  61. package/templates/default/src/components/shared/badges/badge-types.ts +264 -0
  62. package/templates/default/src/components/shared/badges/badges.tsx +479 -0
  63. package/templates/default/src/components/shared/button-group/button-group.tsx +97 -0
  64. package/templates/default/src/components/shared/buttons/app-store-buttons-outline.tsx +454 -0
  65. package/templates/default/src/components/shared/buttons/app-store-buttons.tsx +806 -0
  66. package/templates/default/src/components/shared/buttons/button-utility.tsx +87 -0
  67. package/templates/default/src/components/shared/buttons/button.tsx +284 -0
  68. package/templates/default/src/components/shared/buttons/close-button.tsx +39 -0
  69. package/templates/default/src/components/shared/buttons/social-button.tsx +135 -0
  70. package/templates/default/src/components/shared/buttons/social-logos.tsx +115 -0
  71. package/templates/default/src/components/shared/checkbox/checkbox.tsx +120 -0
  72. package/templates/default/src/components/shared/dropdown/dropdown.tsx +138 -0
  73. package/templates/default/src/components/shared/input-dropdown/combobox.tsx +161 -0
  74. package/templates/default/src/components/shared/input-dropdown/dropdown-item.tsx +98 -0
  75. package/templates/default/src/components/shared/input-dropdown/input-dropdown.tsx +172 -0
  76. package/templates/default/src/components/shared/input-dropdown/multi-select.tsx +373 -0
  77. package/templates/default/src/components/shared/input-dropdown/popover.tsx +36 -0
  78. package/templates/default/src/components/shared/input-dropdown/select.tsx +63 -0
  79. package/templates/default/src/components/shared/inputs/file-upload-trigger.tsx +74 -0
  80. package/templates/default/src/components/shared/inputs/form/form.tsx +10 -0
  81. package/templates/default/src/components/shared/inputs/hint-text.tsx +34 -0
  82. package/templates/default/src/components/shared/inputs/input/index.tsx +189 -0
  83. package/templates/default/src/components/shared/inputs/input/input-payment.tsx +134 -0
  84. package/templates/default/src/components/shared/inputs/input/input-with-button.tsx +69 -0
  85. package/templates/default/src/components/shared/inputs/input/input-with-dropdown.tsx +178 -0
  86. package/templates/default/src/components/shared/inputs/input/input-with-prefix.tsx +74 -0
  87. package/templates/default/src/components/shared/inputs/label.tsx +46 -0
  88. package/templates/default/src/components/shared/inputs/textarea/textarea.tsx +82 -0
  89. package/templates/default/src/components/shared/progress-indicators/progress-circles.tsx +176 -0
  90. package/templates/default/src/components/shared/progress-indicators/progress-indicators.tsx +86 -0
  91. package/templates/default/src/components/shared/progress-indicators/simple-circle.tsx +29 -0
  92. package/templates/default/src/components/shared/radio-buttons/radio-buttons.tsx +125 -0
  93. package/templates/default/src/components/shared/radio-groups/radio-group-avatar.tsx +62 -0
  94. package/templates/default/src/components/shared/radio-groups/radio-group-checkbox.tsx +72 -0
  95. package/templates/default/src/components/shared/radio-groups/radio-group-icon-card.tsx +95 -0
  96. package/templates/default/src/components/shared/radio-groups/radio-group-icon-simple.tsx +70 -0
  97. package/templates/default/src/components/shared/radio-groups/radio-group-payment-icon.tsx +71 -0
  98. package/templates/default/src/components/shared/radio-groups/radio-group-radio-button.tsx +76 -0
  99. package/templates/default/src/components/shared/radio-groups/radio-groups.tsx +8 -0
  100. package/templates/default/src/components/shared/slider/slider.tsx +76 -0
  101. package/templates/default/src/components/shared/tags/base-components/tag-checkbox.tsx +47 -0
  102. package/templates/default/src/components/shared/tags/base-components/tag-close-x.tsx +34 -0
  103. package/templates/default/src/components/shared/tags/tags.tsx +162 -0
  104. package/templates/default/src/components/shared/toggle/toggle.tsx +140 -0
  105. package/templates/default/src/components/shared/tooltips/tooltips.tsx +140 -0
  106. package/templates/default/src/components/utils/index.ts +48 -0
  107. package/templates/default/src/components/utils/isDeepEqual.ts +31 -0
  108. package/templates/default/src/components/utils/isReactComponent.ts +22 -0
  109. package/templates/default/src/components/utils/mergeRefs.ts +19 -0
  110. package/templates/default/src/components/utils/useBreakpoint.ts +36 -0
  111. package/templates/default/src/components/utils/uuid.ts +9 -0
  112. package/templates/default/src/fonts/GeistMonoVF.woff +0 -0
  113. package/templates/default/src/fonts/GeistVF.woff +0 -0
  114. package/templates/default/src/hooks/use-resize-observer.tsx +55 -0
  115. package/templates/default/src/providers/theme.tsx +11 -0
  116. package/templates/default/src/styles/colors.css +805 -0
  117. package/templates/default/src/styles/globals.css +86 -0
  118. package/templates/default/src/styles/text-styles.css +177 -0
  119. package/templates/default/src/styles/theme.css +1310 -0
  120. package/templates/default/src/styles/typography.css +428 -0
  121. package/templates/default/tsconfig.json +27 -0
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import type { ReactNode, Ref, RefAttributes } from "react";
4
+ import { createContext } from "react";
5
+ import { ChevronDown, User01 } from "@untitledui/icons";
6
+ import type { SelectProps as AriaSelectProps } from "react-aria-components";
7
+ import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
8
+ import Dot from "@/components/foundations/dot-icon";
9
+ import { cx } from "@/components/utils";
10
+ import Avatar from "../avatar/avatar";
11
+ import type { IconComponentType } from "../badges/badge-types";
12
+ import HintText from "../inputs/hint-text";
13
+ import Label from "../inputs/label";
14
+ import { ComboBox } from "./combobox";
15
+ import type { ListBoxItemIconProps } from "./dropdown-item";
16
+ import Item from "./dropdown-item";
17
+ import { Popover } from "./popover";
18
+
19
+ export type SelectValueType = {
20
+ label: string;
21
+ value: string;
22
+ avatarUrl?: string;
23
+ isDisabled?: boolean;
24
+ supportingText?: string;
25
+ icon?: IconComponentType;
26
+ };
27
+
28
+ type Types = "default" | "iconLeading" | "avatarLeading" | "dotLeading" | "search" | "tags";
29
+ type SelectTypes = "default" | "iconLeading" | "avatarLeading" | "dotLeading";
30
+
31
+ export interface CommonProps {
32
+ hint?: string;
33
+ label?: string;
34
+ tooltip?: string;
35
+ size?: "sm" | "md";
36
+ placeholder?: string;
37
+ }
38
+
39
+ interface SelectProps extends Omit<AriaSelectProps<SelectValueType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
40
+ type?: SelectTypes;
41
+ items?: SelectValueType[];
42
+ popoverClassName?: string;
43
+ placeholderIcon?: IconComponentType;
44
+ children: ReactNode | ((item: SelectValueType) => ReactNode);
45
+ }
46
+
47
+ interface SelectValueProps {
48
+ isOpen: boolean;
49
+ size: "sm" | "md";
50
+ type: SelectTypes;
51
+ isFocused: boolean;
52
+ isDisabled: boolean;
53
+ placeholder?: string;
54
+ ref?: Ref<HTMLButtonElement>;
55
+ placeholderIcon?: IconComponentType;
56
+ }
57
+
58
+ export const sizes = {
59
+ sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
60
+ md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
61
+ };
62
+
63
+ const selectIcons = {
64
+ default: () => <></>,
65
+ avatarLeading: ({ isDisabled }: ListBoxItemIconProps) => (
66
+ <User01 aria-hidden="true" className={cx("size-5 shrink-0 text-fg-quaternary", isDisabled && "text-fg-disabled")} />
67
+ ),
68
+ iconLeading: ({ icon: Icon = User01, isDisabled }: ListBoxItemIconProps) => (
69
+ <Icon aria-hidden="true" className={cx("size-5 shrink-0 text-fg-quaternary", isDisabled && "text-fg-disabled")} />
70
+ ),
71
+ dotLeading: ({ icon: Icon, isDisabled }: ListBoxItemIconProps) =>
72
+ Icon ? (
73
+ <Icon aria-hidden="true" className={cx("flex size-min shrink-0 text-fg-success-secondary", isDisabled && "text-fg-disabled_subtle")} />
74
+ ) : (
75
+ <Dot size="md" className={cx("flex size-min shrink-0 text-fg-success-secondary", isDisabled && "text-fg-disabled_subtle")} />
76
+ ),
77
+ };
78
+
79
+ const SelectValue = (props: SelectValueProps) => {
80
+ const Icon = selectIcons[props.type];
81
+
82
+ return (
83
+ <AriaButton
84
+ className={cx(
85
+ "relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs ring-1 ring-border-primary outline-hidden transition duration-100 ease-linear ring-inset",
86
+ (props.isFocused || props.isOpen) && "ring-2 ring-border-brand",
87
+ props.isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
88
+ )}
89
+ >
90
+ <AriaSelectValue<SelectValueType>
91
+ className={cx("flex h-max w-full items-center justify-start gap-2 truncate text-left align-middle", sizes[props.size].root)}
92
+ >
93
+ {(state) => (
94
+ <>
95
+ {state?.selectedItem?.avatarUrl ? (
96
+ <Avatar size="xs" src={state.selectedItem.avatarUrl} alt={state.selectedItem.label} />
97
+ ) : (
98
+ <Icon {...state?.selectedItem} icon={state?.selectedItem?.icon || props.placeholderIcon} {...props} />
99
+ )}
100
+
101
+ {state.selectedItem ? (
102
+ <section className="flex w-full gap-2 truncate">
103
+ <p className="truncate tt-md-md text-primary">{state.selectedItem?.label}</p>
104
+ {state.selectedItem?.supportingText && <p className="tt-md text-tertiary">{state.selectedItem?.supportingText}</p>}
105
+ </section>
106
+ ) : (
107
+ <p className={cx("tt-md text-placeholder", props.isDisabled && "text-disabled")}>{props.placeholder}</p>
108
+ )}
109
+
110
+ <ChevronDown size={20} aria-hidden="true" className="ml-auto shrink-0 text-fg-quaternary" />
111
+ </>
112
+ )}
113
+ </AriaSelectValue>
114
+ </AriaButton>
115
+ );
116
+ };
117
+
118
+ export const SelectContext = createContext<{ type: Types; size: "sm" | "md" }>({ type: "default", size: "sm" });
119
+
120
+ export const Select = ({
121
+ type = "default",
122
+ placeholder = "Select",
123
+ placeholderIcon,
124
+ size = "sm",
125
+ children,
126
+ items,
127
+ label,
128
+ hint,
129
+ tooltip,
130
+ ...rest
131
+ }: SelectProps) => {
132
+ const itemsWithId = items?.map((item) => ({ ...item, id: item.value }));
133
+
134
+ return (
135
+ <SelectContext.Provider value={{ type, size }}>
136
+ <AriaSelect {...rest}>
137
+ {(state) => (
138
+ <div className="flex flex-col gap-1.5">
139
+ {label && (
140
+ <Label isRequired={state.isRequired} tooltip={tooltip}>
141
+ {label}
142
+ </Label>
143
+ )}
144
+
145
+ <SelectValue
146
+ {...state}
147
+ {...{ type, size, placeholder }}
148
+ placeholderIcon={type === "avatarLeading" || type === "iconLeading" ? placeholderIcon || User01 : undefined}
149
+ />
150
+
151
+ <Popover size={size} className={rest.popoverClassName}>
152
+ <AriaListBox items={itemsWithId} className="size-full outline-hidden">
153
+ {children}
154
+ </AriaListBox>
155
+ </Popover>
156
+
157
+ {hint && <HintText isInvalid={state.isInvalid}>{hint}</HintText>}
158
+ </div>
159
+ )}
160
+ </AriaSelect>
161
+ </SelectContext.Provider>
162
+ );
163
+ };
164
+
165
+ const InputDropdown = Select as typeof Select & {
166
+ ComboBox: typeof ComboBox;
167
+ Item: typeof Item;
168
+ };
169
+ InputDropdown.ComboBox = ComboBox;
170
+ InputDropdown.Item = Item;
171
+
172
+ export { InputDropdown };
@@ -0,0 +1,373 @@
1
+ "use client";
2
+
3
+ import type { FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes } from "react";
4
+ import { createContext, useCallback, useContext, useRef, useState } from "react";
5
+ import { SearchLg } from "@untitledui/icons";
6
+ import { FocusScope, useFilter, useFocusManager } from "react-aria";
7
+ import type { ComboBoxProps as AriaComboBoxProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
8
+ import {
9
+ Button as AriaButton,
10
+ ComboBox as AriaComboBox,
11
+ Group as AriaGroup,
12
+ Input as AriaInput,
13
+ ListBox as AriaListBox,
14
+ ComboBoxStateContext,
15
+ } from "react-aria-components";
16
+ import { useHotkeys } from "react-hotkeys-hook";
17
+ import type { ListData } from "react-stately";
18
+ import { useListData } from "react-stately";
19
+ import { cx } from "@/components/utils";
20
+ import { useResizeObserver } from "@/hooks/use-resize-observer";
21
+ import Avatar from "../avatar/avatar";
22
+ import type { IconComponentType } from "../badges/badge-types";
23
+ import HintText from "../inputs/hint-text";
24
+ import Label from "../inputs/label";
25
+ import { TagCloseX } from "../tags/base-components/tag-close-x";
26
+ import DropdownItem from "./dropdown-item";
27
+ import { type SelectValueType, sizes } from "./input-dropdown";
28
+ import { Popover } from "./popover";
29
+
30
+ const ComboboxContext = createContext<{
31
+ size: "sm" | "md";
32
+ selectedKeys: Key[];
33
+ selectedItems: ListData<SelectValueType>;
34
+ onRemove: (keys: Set<Key>) => void;
35
+ onInputChange: (value: string) => void;
36
+ }>({
37
+ size: "sm",
38
+ selectedKeys: [],
39
+ selectedItems: {} as ListData<SelectValueType>,
40
+ onRemove: () => {},
41
+ onInputChange: () => {},
42
+ });
43
+
44
+ const InnerComboBox = ({ isDisabled, shortcut, shortcutClassName, placeholder }: ComboBoxValueProps) => {
45
+ const focusManager = useFocusManager();
46
+ const selectContext = useContext(ComboboxContext);
47
+
48
+ const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
49
+ const isCaretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
50
+
51
+ if (!isCaretAtStart && event.currentTarget.value !== "") {
52
+ return;
53
+ }
54
+
55
+ switch (event.key) {
56
+ case "Backspace":
57
+ case "ArrowLeft":
58
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
59
+ break;
60
+ case "ArrowRight":
61
+ focusManager?.focusNext({ wrap: false, tabbable: false });
62
+ break;
63
+ }
64
+ };
65
+
66
+ const handleTagKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: Key) => {
67
+ event.preventDefault();
68
+
69
+ const isFirstTag = selectContext?.selectedItems?.items?.[0]?.value === value;
70
+
71
+ switch (event.key) {
72
+ case " ":
73
+ case "Enter":
74
+ case "Backspace":
75
+ if (isFirstTag) {
76
+ focusManager?.focusNext({ wrap: false, tabbable: false });
77
+ } else {
78
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
79
+ }
80
+
81
+ selectContext.onRemove(new Set([value]));
82
+ break;
83
+
84
+ case "ArrowLeft":
85
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
86
+ break;
87
+ case "ArrowRight":
88
+ focusManager?.focusNext({ wrap: false, tabbable: false });
89
+ break;
90
+ }
91
+ };
92
+
93
+ const isSelectionEmpty = selectContext?.selectedItems?.items?.length === 0;
94
+
95
+ return (
96
+ <div className="relative flex w-full flex-1 flex-row flex-wrap items-center justify-start gap-1.5">
97
+ {!isSelectionEmpty &&
98
+ // TODO: Use <TagList /> here
99
+ selectContext?.selectedItems?.items?.map((value) => (
100
+ <span key={value.value} className="flex items-center rounded-md bg-primary py-0.5 pr-1 pl-[5px] ring-1 ring-border-primary ring-inset">
101
+ <Avatar size="xxs" alt={value?.label} src={value?.avatarUrl} />
102
+
103
+ <p className="ml-[5px] truncate tt-sm-md whitespace-nowrap text-secondary select-none">{value?.label}</p>
104
+
105
+ <TagCloseX
106
+ size="md"
107
+ isDisabled={isDisabled}
108
+ className="ml-[3px]"
109
+ // For workaround, onKeyDown is added to the button
110
+ onKeyDown={(event) => handleTagKeyDown(event, value.value)}
111
+ onPress={() => selectContext.onRemove(new Set([value.value]))}
112
+ />
113
+ </span>
114
+ ))}
115
+
116
+ <div className={cx("relative flex min-w-[20%] flex-1 flex-row items-center", !isSelectionEmpty && "ml-0.5", shortcut && "min-w-[30%]")}>
117
+ <AriaInput
118
+ placeholder={placeholder}
119
+ onKeyDown={handleInputKeyDown}
120
+ className="w-full flex-[1_0_0] appearance-none bg-transparent tt-md text-ellipsis text-primary caret-alpha-black/90 placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed disabled:text-disabled disabled:placeholder:text-disabled"
121
+ />
122
+
123
+ {shortcut && (
124
+ <div
125
+ aria-hidden="true"
126
+ className={cx(
127
+ "absolute inset-y-0.5 right-0.5 z-10 flex items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8",
128
+ shortcutClassName,
129
+ )}
130
+ >
131
+ <span
132
+ className={cx(
133
+ "pointer-events-none rounded px-1 py-px tt-xs-md text-quaternary ring-1 ring-border-secondary select-none ring-inset",
134
+ isDisabled && "bg-transparent text-disabled",
135
+ )}
136
+ >
137
+ ⌘K
138
+ </span>
139
+ </div>
140
+ )}
141
+ </div>
142
+ </div>
143
+ );
144
+ };
145
+
146
+ interface ComboBoxValueProps extends RefAttributes<HTMLDivElement> {
147
+ size: "sm" | "md";
148
+ shortcut?: boolean;
149
+ isDisabled?: boolean;
150
+ placeholder?: string;
151
+ shortcutClassName?: string;
152
+ placeholderIcon?: IconComponentType | null;
153
+ onFocus?: FocusEventHandler;
154
+ onPointerEnter?: PointerEventHandler;
155
+ }
156
+
157
+ export const ComboBoxTagsValue = ({
158
+ size,
159
+ isDisabled,
160
+ shortcut,
161
+ placeholder,
162
+ shortcutClassName,
163
+ placeholderIcon: Icon = SearchLg,
164
+ ...otherProps
165
+ }: ComboBoxValueProps) => {
166
+ const state = useContext(ComboBoxStateContext);
167
+
168
+ useHotkeys("meta+k", () => state?.setOpen(true), { enabled: !isDisabled && shortcut });
169
+
170
+ return (
171
+ <AriaGroup
172
+ {...otherProps}
173
+ className={({ isFocusWithin, isDisabled }) =>
174
+ cx(
175
+ "relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-border-primary outline-hidden transition duration-200 ease-in-out ring-inset",
176
+ isDisabled && "cursor-not-allowed bg-disabled_subtle",
177
+ isFocusWithin && "ring-2 ring-border-brand",
178
+ sizes[size].root,
179
+ )
180
+ }
181
+ >
182
+ {Icon && (
183
+ <AriaButton>
184
+ <Icon className="size-5 text-fg-quaternary" />
185
+ </AriaButton>
186
+ )}
187
+
188
+ <FocusScope contain={false} autoFocus={false} restoreFocus={false}>
189
+ <InnerComboBox size={size} isDisabled={isDisabled} shortcut={shortcut} shortcutClassName={shortcutClassName} placeholder={placeholder} />
190
+ </FocusScope>
191
+ </AriaGroup>
192
+ );
193
+ };
194
+
195
+ interface CommonProps {
196
+ hint?: string;
197
+ label?: string;
198
+ tooltip?: string;
199
+ size?: "sm" | "md";
200
+ placeholder?: string;
201
+ }
202
+
203
+ interface ComboBoxProps extends Omit<AriaComboBoxProps<SelectValueType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
204
+ shortcut?: boolean;
205
+ items?: SelectValueType[];
206
+ popoverClassName?: string;
207
+ shortcutClassName?: string;
208
+ selectedItems: ListData<SelectValueType>;
209
+ placeholderIcon?: IconComponentType | null;
210
+ children: AriaListBoxProps<SelectValueType>["children"];
211
+ onItemCleared?: (key: Key) => void;
212
+ onItemInserted?: (key: Key) => void;
213
+ }
214
+
215
+ export const ComboBox = ({
216
+ name,
217
+ items,
218
+ children,
219
+ className,
220
+ size = "sm",
221
+ selectedItems,
222
+ onItemCleared,
223
+ onItemInserted,
224
+ shortcut,
225
+ placeholder = "Search",
226
+ ...props
227
+ }: ComboBoxProps) => {
228
+ const { contains } = useFilter({ sensitivity: "base" });
229
+ const selectedKeys = selectedItems.items.map((i) => i?.value);
230
+
231
+ const filter = useCallback(
232
+ (item: SelectValueType, filterText: string) => {
233
+ return !selectedKeys.includes(item.value) && contains(item.value, filterText);
234
+ },
235
+ [contains, selectedKeys],
236
+ );
237
+
238
+ const accessibleList = useListData({
239
+ initialItems: items,
240
+ filter,
241
+ });
242
+
243
+ const [fieldState, setFieldState] = useState<{
244
+ selectedKey: Key | null;
245
+ inputValue: string;
246
+ }>({
247
+ selectedKey: null,
248
+ inputValue: "",
249
+ });
250
+
251
+ const onRemove = useCallback(
252
+ (keys: Set<Key>) => {
253
+ const key = keys.values().next().value;
254
+
255
+ if (!key) return;
256
+
257
+ selectedItems.remove(key);
258
+ onItemCleared?.(key);
259
+ },
260
+ [selectedItems, onItemCleared],
261
+ );
262
+
263
+ // TODO: `onSelectionChange` is being called on blur thus causing the already deleted item to reappear
264
+ const onSelectionChange = (id: Key | null) => {
265
+ if (!id) {
266
+ return;
267
+ }
268
+
269
+ const item = accessibleList.getItem(id);
270
+
271
+ if (!item) {
272
+ return;
273
+ }
274
+
275
+ if (!selectedKeys.includes(id as string)) {
276
+ selectedItems.append(item);
277
+ setFieldState({
278
+ inputValue: "",
279
+ selectedKey: id,
280
+ });
281
+ onItemInserted?.(id);
282
+ }
283
+
284
+ accessibleList.setFilterText("");
285
+ };
286
+
287
+ const onInputChange = (value: string) => {
288
+ setFieldState((prev) => ({
289
+ inputValue: value,
290
+ selectedKey: value === "" ? null : prev.selectedKey,
291
+ }));
292
+
293
+ accessibleList.setFilterText(value);
294
+ };
295
+
296
+ const placeholderRef = useRef<HTMLDivElement>(null);
297
+ const [popoverWidth, setPopoverWidth] = useState("");
298
+
299
+ // Resize observer for popover width
300
+ const onResize = useCallback(() => {
301
+ if (!placeholderRef.current) return;
302
+ let divRect = placeholderRef.current?.getBoundingClientRect();
303
+ setPopoverWidth(divRect.width + "px");
304
+ }, [placeholderRef, setPopoverWidth]);
305
+
306
+ useResizeObserver({
307
+ ref: placeholderRef,
308
+ onResize: onResize,
309
+ box: "border-box",
310
+ });
311
+
312
+ return (
313
+ <ComboboxContext.Provider
314
+ value={{
315
+ size,
316
+ selectedKeys,
317
+ selectedItems,
318
+ onInputChange,
319
+ onRemove,
320
+ }}
321
+ >
322
+ <AriaComboBox
323
+ allowsEmptyCollection
324
+ menuTrigger="focus"
325
+ items={accessibleList.items}
326
+ onInputChange={onInputChange}
327
+ inputValue={fieldState.inputValue}
328
+ selectedKey={fieldState.selectedKey}
329
+ onSelectionChange={onSelectionChange}
330
+ {...props}
331
+ >
332
+ {(state) => (
333
+ <div className="flex flex-col gap-1.5">
334
+ {props.label && (
335
+ <Label isRequired={state.isRequired} tooltip={props.tooltip}>
336
+ {props.label}
337
+ </Label>
338
+ )}
339
+
340
+ <ComboBoxTagsValue
341
+ size={size}
342
+ shortcut={shortcut}
343
+ ref={placeholderRef}
344
+ placeholder={placeholder}
345
+ // This is a workaround to correctly calculating the trigger width
346
+ // while using ResizeObserver wasn't 100% reliable.
347
+ onFocus={onResize}
348
+ onPointerEnter={onResize}
349
+ {...state}
350
+ {...props}
351
+ />
352
+
353
+ <Popover size={"md"} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={props?.popoverClassName}>
354
+ <AriaListBox selectionMode="multiple" className="size-full outline-hidden">
355
+ {children}
356
+ </AriaListBox>
357
+ </Popover>
358
+
359
+ {props.hint && <HintText isInvalid={state.isInvalid}>{props.hint}</HintText>}
360
+ </div>
361
+ )}
362
+ </AriaComboBox>
363
+ </ComboboxContext.Provider>
364
+ );
365
+ };
366
+
367
+ const MultiSelect = ComboBox as typeof ComboBox & {
368
+ Option: typeof DropdownItem;
369
+ };
370
+
371
+ MultiSelect.Option = DropdownItem;
372
+
373
+ export { MultiSelect as MultiSelect };
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import type { RefAttributes } from "react";
4
+ import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
5
+ import { Popover as AriaPopover } from "react-aria-components";
6
+ import { cx } from "@/components/utils";
7
+
8
+ interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
9
+ size: "sm" | "md";
10
+ }
11
+
12
+ export const Popover = (props: PopoverProps) => {
13
+ return (
14
+ <AriaPopover
15
+ placement="bottom"
16
+ containerPadding={0}
17
+ offset={4}
18
+ {...props}
19
+ className={(state) =>
20
+ cx(
21
+ "max-h-64! w-(--trigger-width) overflow-x-hidden overflow-y-auto rounded-lg bg-primary py-1 shadow-lg ring-1 ring-border-secondary_alt outline-hidden will-change-transform",
22
+
23
+ // scrollbar styles
24
+ // "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-alpha-black/15 [&::-webkit-scrollbar-track]:rounded-full [&::-webkit-scrollbar-track]:bg-primary [&::-webkit-scrollbar]:w-2",
25
+ state.isEntering &&
26
+ "ease-out animate-in animation-duration-150 fade-in placement-right:origin-left placement-right:slide-in-from-left-0.5 placement-top:origin-bottom placement-top:slide-in-from-bottom-0.5 placement-bottom:origin-top placement-bottom:slide-in-from-top-0.5",
27
+ state.isExiting &&
28
+ "animation-duration-100 ease-in animate-out fade-out placement-right:origin-left placement-right:slide-out-to-left-0.5 placement-top:origin-bottom placement-top:slide-out-to-bottom-0.5 placement-bottom:origin-top placement-bottom:slide-out-to-top-0.5",
29
+ props.size === "md" && "max-h-80!",
30
+
31
+ typeof props.className === "function" ? props.className(state) : props.className,
32
+ )
33
+ }
34
+ />
35
+ );
36
+ };
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { type ChangeEventHandler, useId } from "react";
4
+ import { ChevronDown } from "@untitledui/icons";
5
+ import HintText from "../inputs/hint-text";
6
+ import Label from "../inputs/label";
7
+
8
+ export const NativeSelect = (props: {
9
+ label?: string;
10
+ hint?: string;
11
+ value?: string;
12
+ options: { label: string; value: string; disabled?: boolean }[];
13
+ onChange?: ChangeEventHandler<HTMLSelectElement>;
14
+ }) => {
15
+ const id = useId();
16
+ const selectId = `select-native-${id}`;
17
+ const hintId = `select-native-hint-${id}`;
18
+
19
+ return (
20
+ <div className="w-full">
21
+ {props.label && (
22
+ <Label htmlFor={selectId} id={selectId} className="mb-1.5">
23
+ {props.label}
24
+ </Label>
25
+ )}
26
+
27
+ <div className="relative grid w-full items-center">
28
+ <select
29
+ id={selectId}
30
+ value={props.value}
31
+ defaultValue="default"
32
+ onChange={props.onChange}
33
+ aria-describedby={hintId}
34
+ aria-labelledby={selectId}
35
+ className="appearance-none rounded-lg bg-primary px-3.5 py-2.5 tt-md-md text-primary shadow-xs ring-1 ring-border-primary outline-hidden transition duration-200 ease-in-out ring-inset placeholder:text-fg-quaternary focus:ring-2 focus:ring-border-brand disabled:cursor-not-allowed disabled:bg-disabled_subtle disabled:text-disabled"
36
+ >
37
+ <option value="default" disabled defaultChecked>
38
+ Select team member
39
+ </option>
40
+ {props.options.map((opt) => (
41
+ <option key={opt.value} value={opt.value}>
42
+ {opt.label}
43
+ </option>
44
+ ))}
45
+ </select>
46
+ <span className="absolute right-3.5">
47
+ <ChevronDown aria-hidden="true" size={20} className="text-fg-quaternary" />
48
+ </span>
49
+ </div>
50
+
51
+ {props.hint && (
52
+ <HintText className="mt-2" id={hintId}>
53
+ {props.hint}
54
+ </HintText>
55
+ )}
56
+ </div>
57
+ );
58
+ };
59
+
60
+ NativeSelect.args = {
61
+ label: "Team member",
62
+ hint: "This is a hint text to help user.",
63
+ };