@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.
- package/README.md +16 -0
- package/bin/dataif.js +623 -0
- package/package.json +26 -0
- package/scripts/build-template.mjs +72 -0
- package/templates/dataif/README.md +157 -0
- package/templates/dataif/infra/.env.example +119 -0
- package/templates/dataif/infra/.env.stg.example +119 -0
- package/templates/dataif/infra/airflow/Dockerfile +11 -0
- package/templates/dataif/infra/airflow/Dockerfile.release +17 -0
- package/templates/dataif/infra/airflow/requirements.txt +3 -0
- package/templates/dataif/infra/docker-compose.yml +306 -0
- package/templates/dataif/infra/init-db/01-init-dataif.sh +129 -0
- package/templates/dataif/infra/init-db/pnp-curated-views.sqlinc +444 -0
- package/templates/dataif/infra/init-db/pnp-raw-staging-curated.sqlinc +701 -0
- package/templates/dataif/infra/keycloak/Dockerfile +4 -0
- package/templates/dataif/infra/keycloak/realm-dataif.json +73 -0
- package/templates/dataif/infra/ollama/Dockerfile +9 -0
- package/templates/dataif/infra/ollama/bootstrap-model.sh +100 -0
- package/templates/dataif/infra/ollama/sabia-7b.Modelfile +14 -0
- package/templates/dataif/infra/postgres/Dockerfile +4 -0
- package/templates/dataif/pipelines/airflow/dags/generated/.gitkeep +1 -0
- package/templates/dataif/pipelines/airflow/dags/generated/2020_financeiro_fcc6f1f3_sync.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/airflow/pnp_pipeline_factory.py +167 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/connector.py +28 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/base/types.py +14 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/config.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/connector.py +558 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/powerbi_microdados.py +728 -0
- package/templates/dataif/pipelines/dataif_pipelines/connectors/nilo_pecanha/transform.py +296 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/jobs/nilo_pipeline.py +112 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/__init__.py +21 -0
- package/templates/dataif/pipelines/dataif_pipelines/orchestration/pnp_workflow.py +783 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/__init__.py +1 -0
- package/templates/dataif/pipelines/dataif_pipelines/repositories/pnp_raw_repository.py +860 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/__init__.py +19 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_curated_service.py +66 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_download_service.py +534 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_quality_service.py +9 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_raw_ingestion_service.py +124 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/pnp_staging_service.py +271 -0
- package/templates/dataif/pipelines/dataif_pipelines/services/powerbi_catalog_service.py +159 -0
- package/templates/dataif/pipelines/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/pipelines/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/pipelines/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/pipelines/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/pipelines/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/pipelines/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/pipelines/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/pipelines/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/pipelines/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/pipelines/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/pipelines/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- package/templates/dataif/pipelines/sql/views_curated/070_vw_pnp_vanna.sql +115 -0
- package/templates/dataif/scripts/configure-env.sh +149 -0
- package/templates/dataif/scripts/create_metabase_pnp_dashboard.py +943 -0
- package/templates/dataif/scripts/create_metabase_pnp_matriculas_dashboard.py +580 -0
- package/templates/dataif/scripts/deploy.sh +79 -0
- package/templates/dataif/scripts/fix_metabase_template_tag_ids.py +91 -0
- package/templates/dataif/scripts/pnp_powerbi_microdados_probe.py +14 -0
- package/templates/dataif/scripts/pnp_validate_raw_run.py +330 -0
- package/templates/dataif/scripts/publish-images.sh +31 -0
- package/templates/dataif/scripts/sync_metabase_dashboard_field_filters.py +241 -0
- package/templates/dataif/scripts/use-vanna-ollama.sh +139 -0
- package/templates/dataif/services/api/.dockerignore +18 -0
- package/templates/dataif/services/api/Dockerfile +12 -0
- package/templates/dataif/services/api/app/__init__.py +1 -0
- package/templates/dataif/services/api/app/auth.py +48 -0
- package/templates/dataif/services/api/app/config.py +59 -0
- package/templates/dataif/services/api/app/keycloak_admin.py +215 -0
- package/templates/dataif/services/api/app/main.py +2432 -0
- package/templates/dataif/services/api/app/metabase_admin.py +191 -0
- package/templates/dataif/services/api/app/metabase_bootstrap.py +44 -0
- package/templates/dataif/services/api/app/metabase_embed.py +15 -0
- package/templates/dataif/services/api/app/pnp_dag_provisioner.py +113 -0
- package/templates/dataif/services/api/app/pnp_instance_repository.py +951 -0
- package/templates/dataif/services/api/app/pnp_powerbi.py +438 -0
- package/templates/dataif/services/api/app/vanna_client.py +32 -0
- package/templates/dataif/services/api/requirements.txt +9 -0
- package/templates/dataif/services/vanna/.dockerignore +18 -0
- package/templates/dataif/services/vanna/Dockerfile +12 -0
- package/templates/dataif/services/vanna/app/config.py +57 -0
- package/templates/dataif/services/vanna/app/main.py +108 -0
- package/templates/dataif/services/vanna/app/runtime_config.py +114 -0
- package/templates/dataif/services/vanna/app/sql_guard.py +123 -0
- package/templates/dataif/services/vanna/app/vanna_engine.py +382 -0
- package/templates/dataif/services/vanna/requirements.txt +8 -0
- package/templates/dataif/services/web/.dockerignore +13 -0
- package/templates/dataif/services/web/Dockerfile +16 -0
- package/templates/dataif/services/web/index.html +12 -0
- package/templates/dataif/services/web/nginx.conf +74 -0
- package/templates/dataif/services/web/package-lock.json +4397 -0
- package/templates/dataif/services/web/package.json +32 -0
- package/templates/dataif/services/web/postcss.config.mjs +5 -0
- package/templates/dataif/services/web/src/App.jsx +2817 -0
- package/templates/dataif/services/web/src/adminAuth.js +245 -0
- package/templates/dataif/services/web/src/assets/avatar_placeholder.png +0 -0
- package/templates/dataif/services/web/src/assets/github_logo_icon_229278.svg +1 -0
- package/templates/dataif/services/web/src/assets/if-logo.png +0 -0
- package/templates/dataif/services/web/src/assets/if.svg +0 -0
- package/templates/dataif/services/web/src/assets/pnp-horizontal.svg +1 -0
- package/templates/dataif/services/web/src/components/AppHeader.jsx +233 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/mobile-header.tsx +56 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-account-card.tsx +209 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item-button.tsx +67 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-item.tsx +108 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/base-components/nav-list.tsx +83 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/config.ts +23 -0
- package/templates/dataif/services/web/src/components/application/app-navigation/header-navigation.tsx +240 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-base.tsx +376 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-dot.tsx +52 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination-line.tsx +48 -0
- package/templates/dataif/services/web/src/components/application/pagination/pagination.tsx +328 -0
- package/templates/dataif/services/web/src/components/application/tabs/tabs.tsx +223 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar-label-group.tsx +28 -0
- package/templates/dataif/services/web/src/components/base/avatar/avatar.tsx +129 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-add-button.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-company-icon.tsx +24 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/avatar-online-indicator.tsx +29 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/index.tsx +4 -0
- package/templates/dataif/services/web/src/components/base/avatar/base-components/verified-tick.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/badges/badge-types.ts +264 -0
- package/templates/dataif/services/web/src/components/base/badges/badges.tsx +415 -0
- package/templates/dataif/services/web/src/components/base/button-group/button-group.tsx +104 -0
- package/templates/dataif/services/web/src/components/base/buttons/button.tsx +267 -0
- package/templates/dataif/services/web/src/components/base/input/hint-text.tsx +31 -0
- package/templates/dataif/services/web/src/components/base/input/input.tsx +269 -0
- package/templates/dataif/services/web/src/components/base/input/label.tsx +48 -0
- package/templates/dataif/services/web/src/components/base/radio-buttons/radio-buttons.tsx +127 -0
- package/templates/dataif/services/web/src/components/base/select/combobox.tsx +150 -0
- package/templates/dataif/services/web/src/components/base/select/multi-select.tsx +361 -0
- package/templates/dataif/services/web/src/components/base/select/popover.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/select/select-item.tsx +95 -0
- package/templates/dataif/services/web/src/components/base/select/select-native.tsx +67 -0
- package/templates/dataif/services/web/src/components/base/select/select.tsx +144 -0
- package/templates/dataif/services/web/src/components/base/tags/base-components/tag-close-x.tsx +32 -0
- package/templates/dataif/services/web/src/components/base/tooltip/tooltip.tsx +107 -0
- package/templates/dataif/services/web/src/components/foundations/dot-icon.tsx +22 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo-minimal.tsx +170 -0
- package/templates/dataif/services/web/src/components/foundations/logo/untitledui-logo.tsx +58 -0
- package/templates/dataif/services/web/src/hooks/use-breakpoint.ts +34 -0
- package/templates/dataif/services/web/src/hooks/use-resize-observer.ts +67 -0
- package/templates/dataif/services/web/src/main.jsx +14 -0
- package/templates/dataif/services/web/src/providers/theme-provider.jsx +62 -0
- package/templates/dataif/services/web/src/styles/globals.css +60 -0
- package/templates/dataif/services/web/src/styles/theme.css +1326 -0
- package/templates/dataif/services/web/src/styles/typography.css +430 -0
- package/templates/dataif/services/web/src/styles.css +1287 -0
- package/templates/dataif/services/web/src/utils/cx.ts +24 -0
- package/templates/dataif/services/web/src/utils/is-react-component.ts +33 -0
- package/templates/dataif/services/web/vite.config.js +14 -0
- package/templates/dataif/sql/ddl/001_schemas.sql +6 -0
- package/templates/dataif/sql/ddl/003_pnp_raw_staging_curated.sql +699 -0
- package/templates/dataif/sql/migrations/001_pnp_phase1_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/002_pnp_phase2_admin_config_backfill.sql +184 -0
- package/templates/dataif/sql/migrations/003_pnp_phase3_raw_tabular_backfill.sql +3 -0
- package/templates/dataif/sql/migrations/004_pnp_phase3_raw_backfill_support_index.sql +3 -0
- package/templates/dataif/sql/migrations/005_pnp_phase7_staging_support_indexes.sql +2 -0
- package/templates/dataif/sql/migrations/006_pnp_phase7_staging_autovacuum_tuning.sql +2 -0
- package/templates/dataif/sql/migrations/007_pnp_phase7b_run_packages.sql +20 -0
- package/templates/dataif/sql/migrations/008_pnp_phase7a_pipeline_endpoints.sql +169 -0
- package/templates/dataif/sql/migrations/009_pnp_phase8_curated.sql +35 -0
- package/templates/dataif/sql/migrations/010_pnp_phase10_staging_incremental_upsert.sql +3 -0
- package/templates/dataif/sql/migrations/010_pnp_pipeline_uuid.sql +51 -0
- package/templates/dataif/sql/migrations/011_app_settings.sql +7 -0
- package/templates/dataif/sql/staging/020_pnp_matriculas.sql +112 -0
- package/templates/dataif/sql/staging/030_pnp_eficiencia_academica.sql +83 -0
- package/templates/dataif/sql/staging/040_pnp_servidores.sql +90 -0
- package/templates/dataif/sql/staging/050_pnp_financeiro.sql +72 -0
- package/templates/dataif/sql/views_curated/003_vw_pnp_microdados_admin.sql +160 -0
- package/templates/dataif/sql/views_curated/004_mv_pnp_dashboard_fast.sql +204 -0
- package/templates/dataif/sql/views_curated/010_vw_pnp_admin_ingestao.sql +51 -0
- package/templates/dataif/sql/views_curated/020_vw_pnp_qualidade_dados.sql +114 -0
- package/templates/dataif/sql/views_curated/030_vw_pnp_matriculas.sql +67 -0
- package/templates/dataif/sql/views_curated/040_vw_pnp_eficiencia.sql +33 -0
- package/templates/dataif/sql/views_curated/050_vw_pnp_servidores.sql +30 -0
- package/templates/dataif/sql/views_curated/060_vw_pnp_financeiro.sql +22 -0
- 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
|
+
};
|