@koobiq/react-primitives 0.3.1 → 0.4.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.
@@ -6,3 +6,6 @@ export * from './useRadio';
6
6
  export * from './useRadioGroup';
7
7
  export * from './useRadioGroupState';
8
8
  export * from './useNumberField';
9
+ export * from './useMultiSelect';
10
+ export * from './useMultiSelectState';
11
+ export * from './useMultiSelectListState';
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes, RefObject } from 'react';
2
+ import type { ValidationResult } from '@koobiq/react-core';
3
+ import type { AriaListBoxOptions } from '@react-aria/listbox';
4
+ import type { AriaSelectProps, AriaButtonProps } from '../index';
5
+ import type { MultiSelectProps as MultiSelectStateProps, MultiSelectState } from './useMultiSelectState';
6
+ type MultiSelectProps<T> = Omit<AriaSelectProps<T>, 'onSelectionChange'> & {
7
+ disallowEmptySelection?: boolean;
8
+ onSelectionChange?: MultiSelectStateProps<T>['onSelectionChange'];
9
+ };
10
+ interface MultiSelectAria<T> extends ValidationResult {
11
+ /** Props for the label element. */
12
+ labelProps: HTMLAttributes<HTMLElement>;
13
+ /** Props for the popup trigger element. */
14
+ triggerProps: AriaButtonProps;
15
+ /** Props for the element representing the selected value. */
16
+ valueProps: HTMLAttributes<HTMLElement>;
17
+ /** Props for the popup. */
18
+ menuProps: AriaListBoxOptions<T>;
19
+ /** Props for the select's description element, if any. */
20
+ descriptionProps: HTMLAttributes<HTMLElement>;
21
+ /** Props for the select's error message element, if any. */
22
+ errorMessageProps: HTMLAttributes<HTMLElement>;
23
+ }
24
+ export declare function useMultiSelect<T>(props: MultiSelectProps<T>, state: MultiSelectState<T>, ref: RefObject<HTMLElement>): MultiSelectAria<T>;
25
+ export {};
@@ -0,0 +1,149 @@
1
+ import { useMemo } from "react";
2
+ import { useCollator, filterDOMProps, mergeProps, useId, chain, setInteractionModality } from "@koobiq/react-core";
3
+ import { useField } from "@react-aria/label";
4
+ import { useMenuTrigger } from "@react-aria/menu";
5
+ import { ListKeyboardDelegate, useTypeSelect } from "@react-aria/selection";
6
+ function useMultiSelect(props, state, ref) {
7
+ const { disallowEmptySelection, isDisabled } = props;
8
+ const collator = useCollator({ usage: "search", sensitivity: "base" });
9
+ const delegate = useMemo(
10
+ () => new ListKeyboardDelegate(
11
+ state.collection,
12
+ state.disabledKeys,
13
+ null,
14
+ collator
15
+ ),
16
+ [state.collection, state.disabledKeys, collator]
17
+ );
18
+ const { menuTriggerProps, menuProps } = useMenuTrigger(
19
+ {
20
+ isDisabled,
21
+ type: "listbox"
22
+ },
23
+ state,
24
+ ref
25
+ );
26
+ const triggerOnKeyDown = (e) => {
27
+ if (state.selectionMode === "single") {
28
+ switch (e.key) {
29
+ case "ArrowLeft": {
30
+ e.preventDefault();
31
+ const key = state.selectedKeys.size > 0 ? delegate.getKeyAbove(
32
+ state.selectedKeys.values().next().value
33
+ ) : delegate.getFirstKey();
34
+ if (key) {
35
+ state.setSelectedKeys([key]);
36
+ }
37
+ break;
38
+ }
39
+ case "ArrowRight": {
40
+ e.preventDefault();
41
+ const key = state.selectedKeys.size > 0 ? delegate.getKeyBelow(
42
+ state.selectedKeys.values().next().value
43
+ ) : delegate.getFirstKey();
44
+ if (key) {
45
+ state.setSelectedKeys([key]);
46
+ }
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ };
52
+ const { typeSelectProps } = useTypeSelect({
53
+ keyboardDelegate: delegate,
54
+ selectionManager: state.selectionManager,
55
+ onTypeSelect(key) {
56
+ state.setSelectedKeys([key]);
57
+ }
58
+ });
59
+ const { isInvalid, validationErrors, validationDetails } = state.displayValidation;
60
+ const { labelProps, fieldProps, errorMessageProps, descriptionProps } = useField({
61
+ ...props,
62
+ isInvalid,
63
+ labelElementType: "span",
64
+ errorMessage: props.errorMessage || validationErrors
65
+ });
66
+ typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
67
+ delete typeSelectProps.onKeyDownCapture;
68
+ const domProps = filterDOMProps(props, { labelable: true });
69
+ const triggerProps = mergeProps(
70
+ typeSelectProps,
71
+ menuTriggerProps,
72
+ fieldProps
73
+ );
74
+ const valueId = useId();
75
+ return {
76
+ labelProps: {
77
+ ...labelProps,
78
+ onClick: () => {
79
+ if (!props.isDisabled) {
80
+ ref.current?.focus();
81
+ setInteractionModality("keyboard");
82
+ }
83
+ }
84
+ },
85
+ isInvalid,
86
+ validationErrors,
87
+ validationDetails,
88
+ errorMessageProps,
89
+ descriptionProps,
90
+ triggerProps: mergeProps(domProps, {
91
+ ...triggerProps,
92
+ onKeyDown: chain(
93
+ triggerProps.onKeyDown,
94
+ triggerOnKeyDown,
95
+ props.onKeyDown
96
+ ),
97
+ onKeyUp: props.onKeyUp,
98
+ "aria-labelledby": [
99
+ triggerProps["aria-labelledby"],
100
+ triggerProps["aria-label"] && !triggerProps["aria-labelledby"] ? triggerProps.id : null,
101
+ valueId
102
+ ].filter(Boolean).join(" "),
103
+ onFocus(e) {
104
+ if (state.isFocused) {
105
+ return;
106
+ }
107
+ if (props.onFocus) {
108
+ props.onFocus(e);
109
+ }
110
+ state.setFocused(true);
111
+ },
112
+ onBlur(e) {
113
+ if (state.isOpen) {
114
+ return;
115
+ }
116
+ if (props.onBlur) {
117
+ props.onBlur(e);
118
+ }
119
+ state.setFocused(false);
120
+ }
121
+ }),
122
+ valueProps: {
123
+ id: valueId
124
+ },
125
+ menuProps: {
126
+ ...menuProps,
127
+ disallowEmptySelection,
128
+ autoFocus: state.focusStrategy || true,
129
+ shouldSelectOnPressUp: true,
130
+ shouldFocusOnHover: true,
131
+ onBlur: (e) => {
132
+ if (e.currentTarget.contains(e.relatedTarget)) {
133
+ return;
134
+ }
135
+ if (props.onBlur) {
136
+ props.onBlur(e);
137
+ }
138
+ state.setFocused(false);
139
+ },
140
+ "aria-labelledby": [
141
+ fieldProps["aria-labelledby"],
142
+ triggerProps["aria-label"] && !fieldProps["aria-labelledby"] ? triggerProps.id : null
143
+ ].filter(Boolean).join(" ")
144
+ }
145
+ };
146
+ }
147
+ export {
148
+ useMultiSelect
149
+ };
@@ -0,0 +1,16 @@
1
+ import type { Key } from 'react';
2
+ import type { Node, CollectionBase, MultipleSelection } from '@koobiq/react-core';
3
+ import type { ListState } from '@react-stately/list';
4
+ export interface MultiSelectListProps<T> extends CollectionBase<T>, MultipleSelection {
5
+ }
6
+ export interface MultiSelectListState<T> extends ListState<T> {
7
+ /** The keys for the currently selected items. */
8
+ selectedKeys: Set<Key>;
9
+ /** Sets the selected keys. */
10
+ setSelectedKeys(keys: Iterable<Key>): void;
11
+ /** The value of the currently selected items. */
12
+ selectedItems: Node<T>[] | null;
13
+ /** The type of selection. */
14
+ selectionMode: MultipleSelection['selectionMode'];
15
+ }
16
+ export declare function useMultiSelectListState<T extends object>(props: MultiSelectListProps<T>): MultiSelectListState<T>;
@@ -0,0 +1,36 @@
1
+ import { useMemo } from "react";
2
+ import { useListState } from "@react-stately/list";
3
+ function useMultiSelectListState(props) {
4
+ const {
5
+ collection,
6
+ disabledKeys,
7
+ selectionManager,
8
+ selectionManager: { setSelectedKeys, selectedKeys, selectionMode }
9
+ } = useListState(props);
10
+ const missingKeys = useMemo(() => {
11
+ if (selectedKeys.size !== 0) {
12
+ return Array.from(selectedKeys).filter(Boolean).filter((key) => !collection.getItem(key));
13
+ }
14
+ return [];
15
+ }, [selectedKeys, collection]);
16
+ const selectedItems = selectedKeys.size !== 0 ? Array.from(selectedKeys).map((key) => collection.getItem(key)).filter(Boolean) : null;
17
+ if (missingKeys.length) {
18
+ console.warn(
19
+ `Select: Keys "${missingKeys.join(
20
+ ", "
21
+ )}" passed to "selectedKeys" are not present in the collection.`
22
+ );
23
+ }
24
+ return {
25
+ collection,
26
+ disabledKeys,
27
+ selectionManager,
28
+ selectionMode,
29
+ selectedKeys,
30
+ setSelectedKeys: setSelectedKeys.bind(selectionManager),
31
+ selectedItems
32
+ };
33
+ }
34
+ export {
35
+ useMultiSelectListState
36
+ };
@@ -0,0 +1,19 @@
1
+ import type { AsyncLoadable, CollectionBase, FocusableProps, InputBase, LabelableProps, MultipleSelection, TextInputBase, Validation } from '@koobiq/react-core';
2
+ import type { FormValidationState } from '@react-stately/form';
3
+ import type { MenuTriggerState } from '@react-stately/menu';
4
+ import type { OverlayTriggerProps } from '@react-types/overlays';
5
+ import type { MultiSelectListState } from './useMultiSelectListState';
6
+ export interface MultiSelectProps<T> extends CollectionBase<T>, AsyncLoadable, Omit<InputBase, 'isReadOnly'>, Validation, LabelableProps, TextInputBase, MultipleSelection, FocusableProps, OverlayTriggerProps {
7
+ /**
8
+ * Whether the menu should automatically flip direction when space is limited.
9
+ * @default true
10
+ */
11
+ shouldFlip?: boolean;
12
+ }
13
+ export interface MultiSelectState<T> extends MultiSelectListState<T>, MenuTriggerState, FormValidationState {
14
+ /** Whether the select is currently focused. */
15
+ isFocused: boolean;
16
+ /** Sets whether the select is focused. */
17
+ setFocused(isFocused: boolean): void;
18
+ }
19
+ export declare function useMultiSelectState<T extends object>({ validate, validationBehavior, ...props }: MultiSelectProps<T>): MultiSelectState<T>;
@@ -0,0 +1,60 @@
1
+ import { useState } from "react";
2
+ import { useFormValidationState } from "@react-stately/form";
3
+ import { useMenuTriggerState } from "@react-stately/menu";
4
+ import { useMultiSelectListState } from "./useMultiSelectListState.js";
5
+ function useMultiSelectState({
6
+ validate,
7
+ validationBehavior,
8
+ ...props
9
+ }) {
10
+ const [isFocused, setFocused] = useState(false);
11
+ const triggerState = useMenuTriggerState(props);
12
+ const listState = useMultiSelectListState({
13
+ ...props,
14
+ onSelectionChange: (keys) => {
15
+ if (props.onSelectionChange != null) {
16
+ if (keys === "all") {
17
+ props.onSelectionChange(new Set(listState.collection.getKeys()));
18
+ } else {
19
+ props.onSelectionChange(keys);
20
+ }
21
+ }
22
+ if (props.selectionMode === "single") {
23
+ triggerState.close();
24
+ }
25
+ }
26
+ });
27
+ const validationState = useFormValidationState({
28
+ ...props,
29
+ validationBehavior,
30
+ validate: (value) => {
31
+ if (!validate) return;
32
+ const keys = Array.from(value);
33
+ return validate(props.selectionMode === "single" ? keys[0] : keys);
34
+ },
35
+ value: listState.selectedKeys
36
+ });
37
+ return {
38
+ ...validationState,
39
+ ...listState,
40
+ ...triggerState,
41
+ close() {
42
+ triggerState.close();
43
+ },
44
+ open() {
45
+ if (listState.collection.size !== 0) {
46
+ triggerState.open();
47
+ }
48
+ },
49
+ toggle(focusStrategy) {
50
+ if (listState.collection.size !== 0) {
51
+ triggerState.toggle(focusStrategy);
52
+ }
53
+ },
54
+ isFocused,
55
+ setFocused
56
+ };
57
+ }
58
+ export {
59
+ useMultiSelectState
60
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { useToggleButtonGroup, useToggleButtonGroupItem, type AriaToggleButtonGroupProps, type AriaToggleButtonGroupItemProps, } from '@react-aria/button';
1
+ export { useToggleButtonGroup, useToggleButtonGroupItem, type AriaButtonProps, type AriaToggleButtonGroupProps, type AriaToggleButtonGroupItemProps, } from '@react-aria/button';
2
2
  export * from '@react-stately/collections';
3
3
  export * from '@react-stately/data';
4
4
  export * from '@react-stately/tree';
@@ -7,6 +7,7 @@ export * from '@react-aria/separator';
7
7
  export * from '@react-aria/overlays';
8
8
  export { useOverlayTriggerState, type OverlayTriggerState, } from '@react-stately/overlays';
9
9
  export * from '@react-aria/dialog';
10
+ export * from '@react-aria/label';
10
11
  export * from '@react-aria/listbox';
11
12
  export * from '@react-stately/list';
12
13
  export * from '@react-aria/select';
@@ -26,6 +27,8 @@ export * from '@react-aria/progress';
26
27
  export type { CalendarProps, CalendarAria, CalendarGridAria, CalendarCellAria, AriaCalendarProps, AriaCalendarCellProps, AriaCalendarGridProps, } from '@react-aria/calendar';
27
28
  export { useCalendar, useCalendarCell, useCalendarGrid, } from '@react-aria/calendar';
28
29
  export * from '@react-stately/calendar';
30
+ export * from '@react-stately/form';
31
+ export * from '@react-aria/selection';
29
32
  export * from './behaviors';
30
33
  export * from './components';
31
34
  export * from './utils';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from "@react-aria/separator";
7
7
  export * from "@react-aria/overlays";
8
8
  import { useOverlayTriggerState } from "@react-stately/overlays";
9
9
  export * from "@react-aria/dialog";
10
+ export * from "@react-aria/label";
10
11
  export * from "@react-aria/listbox";
11
12
  export * from "@react-stately/list";
12
13
  export * from "@react-aria/select";
@@ -25,6 +26,8 @@ export * from "@react-stately/table";
25
26
  export * from "@react-aria/progress";
26
27
  import { useCalendar, useCalendarCell, useCalendarGrid } from "@react-aria/calendar";
27
28
  export * from "@react-stately/calendar";
29
+ export * from "@react-stately/form";
30
+ export * from "@react-aria/selection";
28
31
  import { Provider, removeDataAttributes, useRenderProps } from "./utils/index.js";
29
32
  import { useButton } from "./behaviors/useButton.js";
30
33
  import { useCheckbox } from "./behaviors/useCheckbox.js";
@@ -34,6 +37,9 @@ import { useRadio } from "./behaviors/useRadio.js";
34
37
  import { useRadioGroup } from "./behaviors/useRadioGroup.js";
35
38
  import { useRadioGroupState } from "./behaviors/useRadioGroupState.js";
36
39
  import { useNumberField } from "./behaviors/useNumberField.js";
40
+ import { useMultiSelect } from "./behaviors/useMultiSelect.js";
41
+ import { useMultiSelectState } from "./behaviors/useMultiSelectState.js";
42
+ import { useMultiSelectListState } from "./behaviors/useMultiSelectListState.js";
37
43
  import { Text } from "./components/Text/Text.js";
38
44
  import { TextContext } from "./components/Text/TextContext.js";
39
45
  import { Group } from "./components/Group/Group.js";
@@ -87,6 +93,9 @@ export {
87
93
  useGroupContext,
88
94
  useInputContext,
89
95
  useLink,
96
+ useMultiSelect,
97
+ useMultiSelectListState,
98
+ useMultiSelectState,
90
99
  useNumberField,
91
100
  useOverlayTriggerState,
92
101
  useRadio,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koobiq/react-primitives",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -29,6 +29,7 @@
29
29
  "@react-aria/checkbox": "^3.14.0",
30
30
  "@react-aria/datepicker": "^3.14.5",
31
31
  "@react-aria/dialog": "^3.5.19",
32
+ "@react-aria/label": "^3.7.20",
32
33
  "@react-aria/link": "^3.8.3",
33
34
  "@react-aria/listbox": "^3.14.2",
34
35
  "@react-aria/menu": "^3.18.2",
@@ -37,6 +38,7 @@
37
38
  "@react-aria/progress": "^3.4.17",
38
39
  "@react-aria/radio": "^3.10.9",
39
40
  "@react-aria/select": "^3.15.7",
41
+ "@react-aria/selection": "^3.25.0",
40
42
  "@react-aria/separator": "^3.4.8",
41
43
  "@react-aria/switch": "^3.7.5",
42
44
  "@react-aria/table": "^3.17.5",
@@ -49,18 +51,19 @@
49
51
  "@react-stately/collections": "^3.12.5",
50
52
  "@react-stately/data": "^3.13.0",
51
53
  "@react-stately/datepicker": "^3.14.2",
54
+ "@react-stately/form": "^3.2.0",
52
55
  "@react-stately/list": "^3.12.0",
53
56
  "@react-stately/menu": "^3.9.3",
54
57
  "@react-stately/numberfield": "^3.9.7",
55
58
  "@react-stately/overlays": "^3.6.17",
56
59
  "@react-stately/radio": "^3.10.8",
57
- "@react-stately/select": "^3.6.14",
60
+ "@react-stately/select": "3.7.0",
58
61
  "@react-stately/table": "^3.14.3",
59
62
  "@react-stately/toggle": "^3.7.0",
60
63
  "@react-stately/tooltip": "^3.5.5",
61
64
  "@react-stately/tree": "^3.8.9",
62
- "@koobiq/logger": "0.3.1",
63
- "@koobiq/react-core": "0.3.1"
65
+ "@koobiq/react-core": "0.4.0",
66
+ "@koobiq/logger": "0.4.0"
64
67
  },
65
68
  "peerDependencies": {
66
69
  "react": "18.x || 19.x",