@dataif/cli 0.1.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 (183) hide show
  1. package/README.md +16 -0
  2. package/bin/dataif.js +623 -0
  3. package/package.json +26 -0
  4. package/scripts/build-template.mjs +72 -0
  5. package/templates/dataif/README.md +157 -0
  6. package/templates/dataif/infra/.env.example +119 -0
  7. package/templates/dataif/infra/.env.stg.example +119 -0
  8. package/templates/dataif/infra/airflow/Dockerfile +11 -0
  9. package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
  10. package/templates/dataif/infra/airflow/requirements.txt +3 -0
  11. package/templates/dataif/infra/docker-compose.yml +306 -0
  12. package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
  13. package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
  14. package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
  15. package/templates/dataif/infra/keycloak/Dockerfile +4 -0
  16. package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
  17. package/templates/dataif/infra/ollama/Dockerfile +9 -0
  18. package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
  19. package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
  20. package/templates/dataif/infra/postgres/Dockerfile +4 -0
  21. package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
  22. package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
  23. package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
  24. package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
  25. package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
  26. package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
  27. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
  28. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
  29. package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
  30. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
  31. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
  32. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
  33. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
  34. package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
  35. package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
  36. package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
  37. package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
  38. package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
  39. package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
  40. package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
  41. package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
  42. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
  43. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
  44. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
  45. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
  46. package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
  47. package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
  48. package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
  49. package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  50. package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
  51. package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
  52. package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  53. package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  54. package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  55. package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  56. package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  57. package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  58. package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  59. package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
  60. package/templates/dataif/scripts/configure-env.sh +149 -0
  61. package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
  62. package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
  63. package/templates/dataif/scripts/deploy.sh +79 -0
  64. package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
  65. package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
  66. package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
  67. package/templates/dataif/scripts/publish-images.sh +31 -0
  68. package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
  69. package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
  70. package/templates/dataif/services/api/.dockerignore +18 -0
  71. package/templates/dataif/services/api/Dockerfile +12 -0
  72. package/templates/dataif/services/api/app/__init__.py +1 -0
  73. package/templates/dataif/services/api/app/auth.py +48 -0
  74. package/templates/dataif/services/api/app/config.py +59 -0
  75. package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
  76. package/templates/dataif/services/api/app/main.py +2432 -0
  77. package/templates/dataif/services/api/app/metabase_admin.py +191 -0
  78. package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
  79. package/templates/dataif/services/api/app/metabase_embed.py +15 -0
  80. package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
  81. package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
  82. package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
  83. package/templates/dataif/services/api/app/vanna_client.py +32 -0
  84. package/templates/dataif/services/api/requirements.txt +9 -0
  85. package/templates/dataif/services/vanna/.dockerignore +18 -0
  86. package/templates/dataif/services/vanna/Dockerfile +12 -0
  87. package/templates/dataif/services/vanna/app/config.py +57 -0
  88. package/templates/dataif/services/vanna/app/main.py +108 -0
  89. package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
  90. package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
  91. package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
  92. package/templates/dataif/services/vanna/requirements.txt +8 -0
  93. package/templates/dataif/services/web/.dockerignore +13 -0
  94. package/templates/dataif/services/web/Dockerfile +16 -0
  95. package/templates/dataif/services/web/index.html +12 -0
  96. package/templates/dataif/services/web/nginx.conf +74 -0
  97. package/templates/dataif/services/web/package-lock.json +4397 -0
  98. package/templates/dataif/services/web/package.json +32 -0
  99. package/templates/dataif/services/web/postcss.config.mjs +5 -0
  100. package/templates/dataif/services/web/src/App.jsx +2817 -0
  101. package/templates/dataif/services/web/src/adminAuth.js +245 -0
  102. package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
  103. package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
  104. package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
  105. package/templates/dataif/services/web/src/assets/if.svg +0 -0
  106. package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
  107. package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
  108. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
  109. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
  110. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
  111. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
  112. package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
  113. package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
  114. package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
  115. package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
  116. package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
  117. package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
  118. package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
  119. package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
  120. package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
  121. package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
  122. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
  123. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
  124. package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
  125. package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
  126. package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
  127. package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
  128. package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
  129. package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
  130. package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
  131. package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
  132. package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
  133. package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
  134. package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
  135. package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
  136. package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
  137. package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
  138. package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
  139. package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
  140. package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
  141. package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
  142. package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
  143. package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
  144. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
  145. package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
  146. package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
  147. package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
  148. package/templates/dataif/services/web/src/main.jsx +14 -0
  149. package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
  150. package/templates/dataif/services/web/src/styles/globals.css +60 -0
  151. package/templates/dataif/services/web/src/styles/theme.css +1326 -0
  152. package/templates/dataif/services/web/src/styles/typography.css +430 -0
  153. package/templates/dataif/services/web/src/styles.css +1287 -0
  154. package/templates/dataif/services/web/src/utils/cx.ts +24 -0
  155. package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
  156. package/templates/dataif/services/web/vite.config.js +14 -0
  157. package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
  158. package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
  159. package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
  160. package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
  161. package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
  162. package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
  163. package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
  164. package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
  165. package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
  166. package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
  167. package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
  168. package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
  169. package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
  170. package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
  171. package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
  172. package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
  173. package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
  174. package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
  175. package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
  176. package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
  177. package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
  178. package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
  179. package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
  180. package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
  181. package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
  182. package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
  183. package/templates/dataif/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
@@ -0,0 +1,127 @@
1
+ import { type ReactNode, type Ref, createContext, useContext } from "react";
2
+ import {
3
+ Radio as AriaRadio,
4
+ RadioGroup as AriaRadioGroup,
5
+ type RadioGroupProps as AriaRadioGroupProps,
6
+ type RadioProps as AriaRadioProps,
7
+ } from "react-aria-components";
8
+ import { cx } from "@/utils/cx";
9
+
10
+ export interface RadioGroupContextType {
11
+ size?: "sm" | "md";
12
+ }
13
+
14
+ const RadioGroupContext = createContext<RadioGroupContextType | null>(null);
15
+
16
+ export interface RadioButtonBaseProps {
17
+ size?: "sm" | "md";
18
+ className?: string;
19
+ isFocusVisible?: boolean;
20
+ isSelected?: boolean;
21
+ isDisabled?: boolean;
22
+ }
23
+
24
+ export const RadioButtonBase = ({ className, isFocusVisible, isSelected, isDisabled, size = "sm" }: RadioButtonBaseProps) => {
25
+ return (
26
+ <div
27
+ className={cx(
28
+ "flex size-4 min-h-4 min-w-4 cursor-pointer appearance-none items-center justify-center rounded-full bg-primary ring-1 ring-primary ring-inset",
29
+ size === "md" && "size-5 min-h-5 min-w-5",
30
+ isSelected && !isDisabled && "bg-brand-solid ring-bg-brand-solid",
31
+ isDisabled && "cursor-not-allowed border-disabled bg-disabled_subtle",
32
+ isFocusVisible && "outline-2 outline-offset-2 outline-focus-ring",
33
+ className,
34
+ )}
35
+ >
36
+ <div
37
+ className={cx(
38
+ "size-1.5 rounded-full bg-fg-white opacity-0 transition-inherit-all",
39
+ size === "md" && "size-2",
40
+ isDisabled && "bg-fg-disabled_subtle",
41
+ isSelected && "opacity-100",
42
+ )}
43
+ />
44
+ </div>
45
+ );
46
+ };
47
+ RadioButtonBase.displayName = "RadioButtonBase";
48
+
49
+ interface RadioButtonProps extends AriaRadioProps {
50
+ size?: "sm" | "md";
51
+ label?: ReactNode;
52
+ hint?: ReactNode;
53
+ ref?: Ref<HTMLLabelElement>;
54
+ }
55
+
56
+ export const RadioButton = ({ label, hint, className, size = "sm", ...ariaRadioProps }: RadioButtonProps) => {
57
+ const context = useContext(RadioGroupContext);
58
+
59
+ size = context?.size ?? size;
60
+
61
+ const sizes = {
62
+ sm: {
63
+ root: "gap-2",
64
+ textWrapper: "",
65
+ label: "text-sm font-medium",
66
+ hint: "text-sm",
67
+ },
68
+ md: {
69
+ root: "gap-3",
70
+ textWrapper: "gap-0.5",
71
+ label: "text-md font-medium",
72
+ hint: "text-md",
73
+ },
74
+ };
75
+
76
+ return (
77
+ <AriaRadio
78
+ {...ariaRadioProps}
79
+ className={(renderProps) =>
80
+ cx(
81
+ "flex items-start",
82
+ renderProps.isDisabled && "cursor-not-allowed",
83
+ sizes[size].root,
84
+ typeof className === "function" ? className(renderProps) : className,
85
+ )
86
+ }
87
+ >
88
+ {({ isSelected, isDisabled, isFocusVisible }) => (
89
+ <>
90
+ <RadioButtonBase
91
+ size={size}
92
+ isSelected={isSelected}
93
+ isDisabled={isDisabled}
94
+ isFocusVisible={isFocusVisible}
95
+ className={label || hint ? "mt-0.5" : ""}
96
+ />
97
+ {(label || hint) && (
98
+ <div className={cx("inline-flex flex-col", sizes[size].textWrapper)}>
99
+ {label && <p className={cx("text-secondary select-none", sizes[size].label)}>{label}</p>}
100
+ {hint && (
101
+ <span className={cx("text-tertiary", sizes[size].hint)} onClick={(event) => event.stopPropagation()}>
102
+ {hint}
103
+ </span>
104
+ )}
105
+ </div>
106
+ )}
107
+ </>
108
+ )}
109
+ </AriaRadio>
110
+ );
111
+ };
112
+ RadioButton.displayName = "RadioButton";
113
+
114
+ interface RadioGroupProps extends RadioGroupContextType, AriaRadioGroupProps {
115
+ children: ReactNode;
116
+ className?: string;
117
+ }
118
+
119
+ export const RadioGroup = ({ children, className, size = "sm", ...props }: RadioGroupProps) => {
120
+ return (
121
+ <RadioGroupContext.Provider value={{ size }}>
122
+ <AriaRadioGroup {...props} className={cx("flex flex-col gap-4", className)}>
123
+ {children}
124
+ </AriaRadioGroup>
125
+ </RadioGroupContext.Provider>
126
+ );
127
+ };
@@ -0,0 +1,150 @@
1
+ import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
2
+ import { useCallback, useContext, useRef, useState } from "react";
3
+ import { SearchLg as SearchIcon } from "@untitledui/icons";
4
+ import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
5
+ import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
6
+ import { HintText } from "@/components/base/input/hint-text";
7
+ import { Label } from "@/components/base/input/label";
8
+ import { Popover } from "@/components/base/select/popover";
9
+ import { type CommonProps, SelectContext, type SelectItemType, sizes } from "@/components/base/select/select";
10
+ import { useResizeObserver } from "@/hooks/use-resize-observer";
11
+ import { cx } from "@/utils/cx";
12
+
13
+ interface ComboBoxProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
14
+ shortcut?: boolean;
15
+ items?: SelectItemType[];
16
+ popoverClassName?: string;
17
+ shortcutClassName?: string;
18
+ children: AriaListBoxProps<SelectItemType>["children"];
19
+ }
20
+
21
+ interface ComboBoxValueProps extends AriaGroupProps {
22
+ size: "sm" | "md";
23
+ shortcut: boolean;
24
+ placeholder?: string;
25
+ shortcutClassName?: string;
26
+ onFocus?: FocusEventHandler;
27
+ onPointerEnter?: PointerEventHandler;
28
+ ref?: RefObject<HTMLDivElement | null>;
29
+ }
30
+
31
+ const ComboBoxValue = ({ size, shortcut, placeholder, shortcutClassName, ...otherProps }: ComboBoxValueProps) => {
32
+ const state = useContext(ComboBoxStateContext);
33
+
34
+ const value = state?.selectedItem?.value || null;
35
+ const inputValue = state?.inputValue || null;
36
+
37
+ const first = inputValue?.split(value?.supportingText)?.[0] || "";
38
+ const last = inputValue?.split(first)[1];
39
+
40
+ return (
41
+ <AriaGroup
42
+ {...otherProps}
43
+ className={({ isFocusWithin, isDisabled }) =>
44
+ cx(
45
+ "relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition-shadow duration-100 ease-linear ring-inset",
46
+ isDisabled && "cursor-not-allowed bg-disabled_subtle",
47
+ isFocusWithin && "ring-2 ring-brand",
48
+ sizes[size].root,
49
+ )
50
+ }
51
+ >
52
+ {({ isDisabled }) => (
53
+ <>
54
+ <SearchIcon className="pointer-events-none size-5 shrink-0 text-fg-quaternary" />
55
+
56
+ <div className="relative flex w-full items-center gap-2">
57
+ {inputValue && (
58
+ <span className="absolute top-1/2 z-0 inline-flex w-full -translate-y-1/2 gap-2 truncate" aria-hidden="true">
59
+ <p className={cx("text-md font-medium text-primary", isDisabled && "text-disabled")}>{first}</p>
60
+ {last && <p className={cx("-ml-0.75 text-md text-tertiary", isDisabled && "text-disabled")}>{last}</p>}
61
+ </span>
62
+ )}
63
+
64
+ <AriaInput
65
+ placeholder={placeholder}
66
+ className="z-10 w-full appearance-none bg-transparent text-md text-transparent caret-alpha-black/90 placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed disabled:text-disabled disabled:placeholder:text-disabled"
67
+ />
68
+ </div>
69
+
70
+ {shortcut && (
71
+ <div
72
+ className={cx(
73
+ "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",
74
+ isDisabled && "to-bg-disabled_subtle",
75
+ sizes[size].shortcut,
76
+ shortcutClassName,
77
+ )}
78
+ >
79
+ <span
80
+ className={cx(
81
+ "pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset",
82
+ isDisabled && "bg-transparent text-disabled",
83
+ )}
84
+ aria-hidden="true"
85
+ >
86
+ ⌘K
87
+ </span>
88
+ </div>
89
+ )}
90
+ </>
91
+ )}
92
+ </AriaGroup>
93
+ );
94
+ };
95
+
96
+ export const ComboBox = ({ placeholder = "Search", shortcut = true, size = "sm", children, items, shortcutClassName, ...otherProps }: ComboBoxProps) => {
97
+ const placeholderRef = useRef<HTMLDivElement>(null);
98
+ const [popoverWidth, setPopoverWidth] = useState("");
99
+
100
+ // Resize observer for popover width
101
+ const onResize = useCallback(() => {
102
+ if (!placeholderRef.current) return;
103
+
104
+ const divRect = placeholderRef.current?.getBoundingClientRect();
105
+
106
+ setPopoverWidth(divRect.width + "px");
107
+ }, [placeholderRef, setPopoverWidth]);
108
+
109
+ useResizeObserver({
110
+ ref: placeholderRef,
111
+ box: "border-box",
112
+ onResize,
113
+ });
114
+
115
+ return (
116
+ <SelectContext.Provider value={{ size }}>
117
+ <AriaComboBox menuTrigger="focus" {...otherProps}>
118
+ {(state) => (
119
+ <div className="flex flex-col gap-1.5">
120
+ {otherProps.label && (
121
+ <Label isRequired={state.isRequired} tooltip={otherProps.tooltip}>
122
+ {otherProps.label}
123
+ </Label>
124
+ )}
125
+
126
+ <ComboBoxValue
127
+ ref={placeholderRef}
128
+ placeholder={placeholder}
129
+ shortcut={shortcut}
130
+ shortcutClassName={shortcutClassName}
131
+ size={size}
132
+ // This is a workaround to correctly calculating the trigger width
133
+ // while using ResizeObserver wasn't 100% reliable.
134
+ onFocus={onResize}
135
+ onPointerEnter={onResize}
136
+ />
137
+
138
+ <Popover size={size} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={otherProps.popoverClassName}>
139
+ <AriaListBox items={items} className="size-full outline-hidden">
140
+ {children}
141
+ </AriaListBox>
142
+ </Popover>
143
+
144
+ {otherProps.hint && <HintText isInvalid={state.isInvalid}>{otherProps.hint}</HintText>}
145
+ </div>
146
+ )}
147
+ </AriaComboBox>
148
+ </SelectContext.Provider>
149
+ );
150
+ };
@@ -0,0 +1,361 @@
1
+ import type { FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
2
+ import { createContext, useCallback, useContext, useRef, useState } from "react";
3
+ import { SearchLg } from "@untitledui/icons";
4
+ import { FocusScope, useFilter, useFocusManager } from "react-aria";
5
+ import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
6
+ import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
7
+ import type { ListData } from "react-stately";
8
+ import { useListData } from "react-stately";
9
+ import { Avatar } from "@/components/base/avatar/avatar";
10
+ import type { IconComponentType } from "@/components/base/badges/badge-types";
11
+ import { HintText } from "@/components/base/input/hint-text";
12
+ import { Label } from "@/components/base/input/label";
13
+ import { Popover } from "@/components/base/select/popover";
14
+ import { type SelectItemType, sizes } from "@/components/base/select/select";
15
+ import { TagCloseX } from "@/components/base/tags/base-components/tag-close-x";
16
+ import { useResizeObserver } from "@/hooks/use-resize-observer";
17
+ import { cx } from "@/utils/cx";
18
+ import { SelectItem } from "./select-item";
19
+
20
+ interface ComboBoxValueProps extends AriaGroupProps {
21
+ size: "sm" | "md";
22
+ shortcut?: boolean;
23
+ isDisabled?: boolean;
24
+ placeholder?: string;
25
+ shortcutClassName?: string;
26
+ placeholderIcon?: IconComponentType | null;
27
+ ref?: RefObject<HTMLDivElement | null>;
28
+ onFocus?: FocusEventHandler;
29
+ onPointerEnter?: PointerEventHandler;
30
+ }
31
+
32
+ const ComboboxContext = createContext<{
33
+ size: "sm" | "md";
34
+ selectedKeys: Key[];
35
+ selectedItems: ListData<SelectItemType>;
36
+ onRemove: (keys: Set<Key>) => void;
37
+ onInputChange: (value: string) => void;
38
+ }>({
39
+ size: "sm",
40
+ selectedKeys: [],
41
+ selectedItems: {} as ListData<SelectItemType>,
42
+ onRemove: () => {},
43
+ onInputChange: () => {},
44
+ });
45
+
46
+ interface MultiSelectProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement> {
47
+ hint?: string;
48
+ label?: string;
49
+ tooltip?: string;
50
+ size?: "sm" | "md";
51
+ placeholder?: string;
52
+ shortcut?: boolean;
53
+ items?: SelectItemType[];
54
+ popoverClassName?: string;
55
+ shortcutClassName?: string;
56
+ selectedItems: ListData<SelectItemType>;
57
+ placeholderIcon?: IconComponentType | null;
58
+ children: AriaListBoxProps<SelectItemType>["children"];
59
+ onItemCleared?: (key: Key) => void;
60
+ onItemInserted?: (key: Key) => void;
61
+ }
62
+
63
+ export const MultiSelectBase = ({
64
+ items,
65
+ children,
66
+ size = "sm",
67
+ selectedItems,
68
+ onItemCleared,
69
+ onItemInserted,
70
+ shortcut,
71
+ placeholder = "Search",
72
+ // Omit these props to avoid conflicts with the `Select` component
73
+ name: _name,
74
+ className: _className,
75
+ ...props
76
+ }: MultiSelectProps) => {
77
+ const { contains } = useFilter({ sensitivity: "base" });
78
+ const selectedKeys = selectedItems.items.map((item) => item.id);
79
+
80
+ const filter = useCallback(
81
+ (item: SelectItemType, filterText: string) => {
82
+ return !selectedKeys.includes(item.id) && contains(item.label || item.supportingText || "", filterText);
83
+ },
84
+ [contains, selectedKeys],
85
+ );
86
+
87
+ const accessibleList = useListData({
88
+ initialItems: items,
89
+ filter,
90
+ });
91
+
92
+ const onRemove = useCallback(
93
+ (keys: Set<Key>) => {
94
+ const key = keys.values().next().value;
95
+
96
+ if (!key) return;
97
+
98
+ selectedItems.remove(key);
99
+ onItemCleared?.(key);
100
+ },
101
+ [selectedItems, onItemCleared],
102
+ );
103
+
104
+ const onSelectionChange = (id: Key | null) => {
105
+ if (!id) {
106
+ return;
107
+ }
108
+
109
+ const item = accessibleList.getItem(id);
110
+
111
+ if (!item) {
112
+ return;
113
+ }
114
+
115
+ if (!selectedKeys.includes(id as string)) {
116
+ selectedItems.append(item);
117
+ onItemInserted?.(id);
118
+ }
119
+
120
+ accessibleList.setFilterText("");
121
+ };
122
+
123
+ const onInputChange = (value: string) => {
124
+ accessibleList.setFilterText(value);
125
+ };
126
+
127
+ const placeholderRef = useRef<HTMLDivElement>(null);
128
+ const [popoverWidth, setPopoverWidth] = useState("");
129
+
130
+ // Resize observer for popover width
131
+ const onResize = useCallback(() => {
132
+ if (!placeholderRef.current) return;
133
+ let divRect = placeholderRef.current?.getBoundingClientRect();
134
+ setPopoverWidth(divRect.width + "px");
135
+ }, [placeholderRef, setPopoverWidth]);
136
+
137
+ useResizeObserver({
138
+ ref: placeholderRef,
139
+ onResize: onResize,
140
+ box: "border-box",
141
+ });
142
+
143
+ return (
144
+ <ComboboxContext.Provider
145
+ value={{
146
+ size,
147
+ selectedKeys,
148
+ selectedItems,
149
+ onInputChange,
150
+ onRemove,
151
+ }}
152
+ >
153
+ <AriaComboBox
154
+ allowsEmptyCollection
155
+ menuTrigger="focus"
156
+ items={accessibleList.items}
157
+ onInputChange={onInputChange}
158
+ inputValue={accessibleList.filterText}
159
+ // This keeps the combobox popover open and the input value unchanged when an item is selected.
160
+ selectedKey={null}
161
+ onSelectionChange={onSelectionChange}
162
+ {...props}
163
+ >
164
+ {(state) => (
165
+ <div className="flex flex-col gap-1.5">
166
+ {props.label && (
167
+ <Label isRequired={state.isRequired} tooltip={props.tooltip}>
168
+ {props.label}
169
+ </Label>
170
+ )}
171
+
172
+ <MultiSelectTagsValue
173
+ size={size}
174
+ shortcut={shortcut}
175
+ ref={placeholderRef}
176
+ placeholder={placeholder}
177
+ // This is a workaround to correctly calculating the trigger width
178
+ // while using ResizeObserver wasn't 100% reliable.
179
+ onFocus={onResize}
180
+ onPointerEnter={onResize}
181
+ />
182
+
183
+ <Popover size={"md"} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={props?.popoverClassName}>
184
+ <AriaListBox selectionMode="multiple" className="size-full outline-hidden">
185
+ {children}
186
+ </AriaListBox>
187
+ </Popover>
188
+
189
+ {props.hint && <HintText isInvalid={state.isInvalid}>{props.hint}</HintText>}
190
+ </div>
191
+ )}
192
+ </AriaComboBox>
193
+ </ComboboxContext.Provider>
194
+ );
195
+ };
196
+
197
+ const InnerMultiSelect = ({ isDisabled, shortcut, shortcutClassName, placeholder }: Omit<MultiSelectProps, "selectedItems" | "children">) => {
198
+ const focusManager = useFocusManager();
199
+ const comboBoxContext = useContext(ComboboxContext);
200
+ const comboBoxStateContext = useContext(ComboBoxStateContext);
201
+
202
+ const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
203
+ const isCaretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
204
+
205
+ if (!isCaretAtStart && event.currentTarget.value !== "") {
206
+ return;
207
+ }
208
+
209
+ switch (event.key) {
210
+ case "Backspace":
211
+ case "ArrowLeft":
212
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
213
+ break;
214
+ case "ArrowRight":
215
+ focusManager?.focusNext({ wrap: false, tabbable: false });
216
+ break;
217
+ }
218
+ };
219
+
220
+ // Ensure dropdown opens on click even if input is already focused
221
+ const handleInputMouseDown = (_event: React.MouseEvent<HTMLInputElement>) => {
222
+ if (comboBoxStateContext && !comboBoxStateContext.isOpen) {
223
+ comboBoxStateContext.open();
224
+ }
225
+ };
226
+
227
+ const handleTagKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: Key) => {
228
+ // Do nothing when tab is clicked to move focus from the tag to the input element.
229
+ if (event.key === "Tab") {
230
+ return;
231
+ }
232
+
233
+ event.preventDefault();
234
+
235
+ const isFirstTag = comboBoxContext?.selectedItems?.items?.[0]?.id === value;
236
+
237
+ switch (event.key) {
238
+ case " ":
239
+ case "Enter":
240
+ case "Backspace":
241
+ if (isFirstTag) {
242
+ focusManager?.focusNext({ wrap: false, tabbable: false });
243
+ } else {
244
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
245
+ }
246
+
247
+ comboBoxContext.onRemove(new Set([value]));
248
+ break;
249
+
250
+ case "ArrowLeft":
251
+ focusManager?.focusPrevious({ wrap: false, tabbable: false });
252
+ break;
253
+ case "ArrowRight":
254
+ focusManager?.focusNext({ wrap: false, tabbable: false });
255
+ break;
256
+ case "Escape":
257
+ comboBoxStateContext?.close();
258
+ break;
259
+ }
260
+ };
261
+
262
+ const isSelectionEmpty = comboBoxContext?.selectedItems?.items?.length === 0;
263
+
264
+ return (
265
+ <div className="relative flex w-full flex-1 flex-row flex-wrap items-center justify-start gap-1.5">
266
+ {!isSelectionEmpty &&
267
+ comboBoxContext?.selectedItems?.items?.map((value) => (
268
+ <span key={value.id} className="flex items-center rounded-md bg-primary py-0.5 pr-1 pl-1.25 ring-1 ring-primary ring-inset">
269
+ <Avatar size="xxs" alt={value?.label} src={value?.avatarUrl} />
270
+
271
+ <p className="ml-1.25 truncate text-sm font-medium whitespace-nowrap text-secondary select-none">{value?.label}</p>
272
+
273
+ <TagCloseX
274
+ size="md"
275
+ isDisabled={isDisabled}
276
+ className="ml-0.75"
277
+ // For workaround, onKeyDown is added to the button
278
+ onKeyDown={(event) => handleTagKeyDown(event, value.id)}
279
+ onPress={() => comboBoxContext.onRemove(new Set([value.id]))}
280
+ />
281
+ </span>
282
+ ))}
283
+
284
+ <div className={cx("relative flex min-w-[20%] flex-1 flex-row items-center", !isSelectionEmpty && "ml-0.5", shortcut && "min-w-[30%]")}>
285
+ <AriaInput
286
+ placeholder={placeholder}
287
+ onKeyDown={handleInputKeyDown}
288
+ onMouseDown={handleInputMouseDown}
289
+ className="w-full flex-[1_0_0] appearance-none bg-transparent text-md text-ellipsis text-primary caret-alpha-black/90 outline-none placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed disabled:text-disabled disabled:placeholder:text-disabled"
290
+ />
291
+
292
+ {shortcut && (
293
+ <div
294
+ aria-hidden="true"
295
+ className={cx(
296
+ "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",
297
+ shortcutClassName,
298
+ )}
299
+ >
300
+ <span
301
+ className={cx(
302
+ "pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset",
303
+ isDisabled && "bg-transparent text-disabled",
304
+ )}
305
+ >
306
+ ⌘K
307
+ </span>
308
+ </div>
309
+ )}
310
+ </div>
311
+ </div>
312
+ );
313
+ };
314
+
315
+ export const MultiSelectTagsValue = ({
316
+ size,
317
+ shortcut,
318
+ placeholder,
319
+ shortcutClassName,
320
+ placeholderIcon: Icon = SearchLg,
321
+ // Omit this prop to avoid invalid HTML attribute warning
322
+ isDisabled: _isDisabled,
323
+ ...otherProps
324
+ }: ComboBoxValueProps) => {
325
+ return (
326
+ <AriaGroup
327
+ {...otherProps}
328
+ className={({ isFocusWithin, isDisabled }) =>
329
+ cx(
330
+ "relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
331
+ isDisabled && "cursor-not-allowed bg-disabled_subtle",
332
+ isFocusWithin && "ring-2 ring-brand",
333
+ sizes[size].root,
334
+ )
335
+ }
336
+ >
337
+ {({ isDisabled }) => (
338
+ <>
339
+ {Icon && <Icon className="pointer-events-none size-5 text-fg-quaternary" />}
340
+ <FocusScope contain={false} autoFocus={false} restoreFocus={false}>
341
+ <InnerMultiSelect
342
+ isDisabled={isDisabled}
343
+ size={size}
344
+ shortcut={shortcut}
345
+ shortcutClassName={shortcutClassName}
346
+ placeholder={placeholder}
347
+ />
348
+ </FocusScope>
349
+ </>
350
+ )}
351
+ </AriaGroup>
352
+ );
353
+ };
354
+
355
+ const MultiSelect = MultiSelectBase as typeof MultiSelectBase & {
356
+ Item: typeof SelectItem;
357
+ };
358
+
359
+ MultiSelect.Item = SelectItem;
360
+
361
+ export { MultiSelect as MultiSelect };
@@ -0,0 +1,32 @@
1
+ import type { RefAttributes } from "react";
2
+ import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
3
+ import { Popover as AriaPopover } from "react-aria-components";
4
+ import { cx } from "@/utils/cx";
5
+
6
+ interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
7
+ size: "sm" | "md";
8
+ }
9
+
10
+ export const Popover = (props: PopoverProps) => {
11
+ return (
12
+ <AriaPopover
13
+ placement="bottom"
14
+ containerPadding={0}
15
+ offset={4}
16
+ {...props}
17
+ className={(state) =>
18
+ cx(
19
+ "max-h-64! w-(--trigger-width) origin-(--trigger-anchor-point) overflow-x-hidden overflow-y-auto rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary_alt outline-hidden will-change-transform",
20
+
21
+ state.isEntering &&
22
+ "duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
23
+ state.isExiting &&
24
+ "duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
25
+ props.size === "md" && "max-h-80!",
26
+
27
+ typeof props.className === "function" ? props.className(state) : props.className,
28
+ )
29
+ }
30
+ />
31
+ );
32
+ };