Skip to content

Commit 1bff0a0

Browse files
thejacksheltonwmertens
authored andcommitted
feat(combobox): proper disabled behavior, refactor
1 parent 1bc9bca commit 1bff0a0

File tree

7 files changed

+140
-145
lines changed

7 files changed

+140
-145
lines changed

apps/website/src/routes/docs/headless/(components)/combobox/examples.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,17 @@ export const Example01 = component$(() => {
6666
onInputChange$={onInputChange$}
6767
optionValueKey="testValue"
6868
optionLabelKey="testLabel"
69+
optionDisabledKey="disabled"
6970
optionComponent$={$((option: Trainer, index: number) => (
7071
<ComboboxOption
7172
index={index}
7273
option={option}
73-
class="rounded-sm px-2 hover:bg-[#496080] focus:bg-[#496080]"
74+
style={option.disabled ? { color: 'gray' } : {}}
75+
class="rounded-sm px-2 hover:bg-[#496080] aria-selected:bg-[#496080] border-2 border-transparent aria-selected:border-[#abbbce] group"
7476
>
75-
{option.testLabel}
77+
<span class="block group-aria-selected:translate-x-[3px] transition-transform duration-350">
78+
{option.testLabel}
79+
</span>
7680
</ComboboxOption>
7781
))}
7882
class="relative"

packages/kit-headless/src/components/combobox/combobox-context.type.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import { JSXNode, QRL, Signal } from '@builder.io/qwik';
22

33
export interface ComboboxContext {
4-
selectedOptionIndexSig: Signal<number>;
5-
isListboxOpenSig: Signal<boolean | undefined>;
6-
isInputFocusedSig: Signal<boolean | undefined>;
7-
isTriggerFocusedSig: Signal<boolean | undefined>;
4+
// user's source of truth
5+
options: Signal<Array<string | Record<string, any>>>;
6+
optionComponent$?: QRL<(option: any, index: number, ...args: any) => JSXNode>;
7+
8+
// refs
89
listboxRef: Signal<HTMLUListElement | undefined>;
910
inputRef: Signal<HTMLInputElement | undefined>;
1011
triggerRef: Signal<HTMLButtonElement | undefined>;
11-
optionComponent$?: QRL<(option: any, index: number) => JSXNode>;
12+
13+
// internal state
14+
isInputFocusedSig: Signal<boolean | undefined>;
15+
isTriggerFocusedSig: Signal<boolean | undefined>;
16+
isListboxOpenSig: Signal<boolean | undefined>;
17+
highlightedIndexSig: Signal<number>;
18+
selectedOptionIndexSig: Signal<number>;
19+
20+
// option settings
1221
onInputChange$?: QRL<(value: string) => void>;
1322
optionValueKey?: string;
1423
optionLabelKey?: string;
1524
optionDisabledKey?: string;
16-
options: Signal<Array<string | Record<string, any>>>;
17-
highlightedIndexSig: Signal<number>;
1825
}
1926

2027
// Whether it is a string or an object we want to be able to access the value

packages/kit-headless/src/components/combobox/combobox-control.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import {
2-
$,
32
Slot,
43
component$,
54
useContext,
6-
useOnWindow,
75
useSignal,
86
useVisibleTask$,
97
type QwikIntrinsicElements,
@@ -17,20 +15,6 @@ export const ComboboxControl = component$((props: ComboboxControlProps) => {
1715
const context = useContext(ComboboxContextId);
1816
const controlRef = useSignal<HTMLDivElement>();
1917

20-
// will break consumer customization of toggling listbox on input click
21-
const closeULOnOutsideClick$ = $((e: Event) => {
22-
const target = e.target as HTMLElement;
23-
if (
24-
context.isListboxOpenSig.value &&
25-
!context.listboxRef.value?.contains(target) &&
26-
!context.triggerRef.value?.contains(target)
27-
) {
28-
context.isListboxOpenSig.value = false;
29-
}
30-
});
31-
32-
useOnWindow('click', closeULOnOutsideClick$);
33-
3418
useVisibleTask$(function preventFocusChangeTask({ cleanup }) {
3519
if (controlRef.value) {
3620
const handleMousedown = (e: MouseEvent): void => {

packages/kit-headless/src/components/combobox/combobox-input.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ import {
88
} from '@builder.io/qwik';
99
import { KeyCode } from '../../utils';
1010
import ComboboxContextId from './combobox-context-id';
11-
import { getOptionLabel } from './utils';
11+
import {
12+
isOptionDisabled,
13+
getOptionLabel,
14+
getNextEnabledOptionIndex,
15+
getPrevEnabledOptionIndex
16+
} from './utils';
1217

1318
const preventedKeys = [KeyCode.Home, KeyCode.End, KeyCode.PageDown, KeyCode.ArrowUp];
1419

1520
export type ComboboxInputProps = QwikIntrinsicElements['input'];
1621

17-
// Add required context here
1822
export const ComboboxInput = component$((props: ComboboxInputProps) => {
1923
const context = useContext(ComboboxContextId);
2024

@@ -25,27 +29,29 @@ export const ComboboxInput = component$((props: ComboboxInputProps) => {
2529
);
2630

2731
if (e.key === 'ArrowDown') {
28-
if (!context.isListboxOpenSig.value && context.highlightedIndexSig.value === -1) {
29-
context.highlightedIndexSig.value = 0;
30-
}
31-
32-
// If the listbox is already open, move down
33-
if (context.isListboxOpenSig.value) {
34-
context.highlightedIndexSig.value === context.options.value.length - 1
35-
? (context.highlightedIndexSig.value = 0)
36-
: context.highlightedIndexSig.value++;
37-
}
32+
const nextEnabledOptionIndex = getNextEnabledOptionIndex(
33+
context.highlightedIndexSig.value,
34+
context
35+
);
36+
context.highlightedIndexSig.value = nextEnabledOptionIndex;
3837

3938
context.isListboxOpenSig.value = true;
4039
}
4140

4241
if (e.key === 'ArrowUp') {
43-
context.highlightedIndexSig.value === 0
44-
? (context.highlightedIndexSig.value = context.options.value.length - 1)
45-
: context.highlightedIndexSig.value--;
42+
const prevEnabledOptionIndex = getPrevEnabledOptionIndex(
43+
context.highlightedIndexSig.value,
44+
context
45+
);
46+
context.highlightedIndexSig.value = prevEnabledOptionIndex;
4647
}
4748

4849
if (e.key === 'Enter') {
50+
// if they somehow manage to highlight a disabled option (bug)
51+
if (isOptionDisabled(context.highlightedIndexSig.value, context)) {
52+
return;
53+
}
54+
4955
const inputElement = e.target as HTMLInputElement;
5056
inputElement.value = highlightedOptionLabel;
5157
context.isListboxOpenSig.value = false;
@@ -89,6 +95,7 @@ export const ComboboxInput = component$((props: ComboboxInputProps) => {
8995
context.onInputChange$(inputElement.value);
9096
}
9197
}}
98+
onBlur$={() => (context.isListboxOpenSig.value = false)}
9299
onKeyDown$={[onKeydownBehavior$, props.onKeyDown$]}
93100
{...props}
94101
/>

packages/kit-headless/src/components/combobox/combobox-option.tsx

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,29 @@ import {
88
useVisibleTask$,
99
} from '@builder.io/qwik';
1010
import ComboboxContextId from './combobox-context-id';
11-
import { getOptionLabel } from './utils';
11+
import { isOptionDisabled, getOptionLabel } from './utils';
1212

1313
import { Option } from './combobox-context.type';
1414

1515
export type ComboboxOptionProps = {
1616
index: number;
1717
option: Option;
18+
disabled?: boolean;
1819
} & QwikIntrinsicElements['li'];
1920

2021
export const ComboboxOption = component$(
2122
({ index, option, ...props }: ComboboxOptionProps) => {
22-
// const index = (props as ComboboxOptionProps & { _index: number })._index;
2323
option;
24+
2425
const context = useContext(ComboboxContextId);
26+
27+
const isOptionDisabledSig = useComputed$(() => isOptionDisabled(index, context));
28+
2529
const isHighlightedSig = useComputed$(
26-
() => context.highlightedIndexSig.value === index
30+
() => !isOptionDisabledSig.value && context.highlightedIndexSig.value === index
2731
);
2832

29-
useSignal(false);
3033
const optionRef = useSignal<HTMLLIElement>();
31-
// const selectedOptionIndexSig = context.selectedOptionIndexSig;
32-
33-
// TODO: Get rid of this
34-
const computedStyle = useComputed$(() => {
35-
return isHighlightedSig.value
36-
? { border: '2px solid maroon' }
37-
: { border: '2px solid transparent' };
38-
});
3934

4035
useVisibleTask$(function preventFocusChangeTask({ cleanup }) {
4136
if (optionRef.value) {
@@ -63,42 +58,16 @@ export const ComboboxOption = component$(
6358
}
6459
});
6560

66-
// // NOT a signal. The value property on the options array of objects.
67-
// let optionValue, optionLabel, isOptionDisabled;
68-
// if (typeof option === 'string') {
69-
// optionValue = option;
70-
// optionLabel = option;
71-
// isOptionDisabled = false;
72-
// } else {
73-
// const valueKey = context.optionValueKey ?? 'value';
74-
// const labelKey = context.optionLabelKey ?? 'label';
75-
// const disabledKey = context.optionDisabledKey ?? 'disabled';
76-
// if (option[valueKey] === undefined) {
77-
// throw new Error(
78-
// 'Qwik UI: Combobox optionValueKey was not provided, and the option was not a string. Please provide a value for optionValueKey, or ensure that the option is a string.'
79-
// );
80-
// }
81-
82-
// if (option[labelKey] === undefined) {
83-
// throw new Error(
84-
// 'Qwik UI: Combobox optionLabelKey was not provided, and the option was not a string. Please provide a value for optionLabelKey, or ensure that the option is a string.'
85-
// );
86-
// }
87-
88-
// optionValue = option[valueKey];
89-
// optionLabel = option[labelKey];
90-
// isOptionDisabled = option[disabledKey];
91-
// }
92-
9361
return (
9462
<li
9563
{...props}
9664
ref={optionRef}
9765
tabIndex={0}
98-
style={computedStyle.value}
9966
aria-selected={isHighlightedSig.value}
67+
aria-disabled={isOptionDisabledSig.value}
68+
data-disabled={isOptionDisabledSig.value}
10069
onClick$={() => {
101-
if (!context.inputRef.value) {
70+
if (!context.inputRef.value || isOptionDisabledSig.value) {
10271
return;
10372
}
10473

@@ -108,7 +77,6 @@ export const ComboboxOption = component$(
10877
);
10978

11079
context.isListboxOpenSig.value = false;
111-
// context.inputRef.value.focus();
11280
}}
11381
role="option"
11482
onMouseEnter$={() => (context.highlightedIndexSig.value = index)}

packages/kit-headless/src/components/combobox/combobox.tsx

Lines changed: 19 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@ import {
1212
import { Option } from './combobox-context.type';
1313

1414
export type ComboboxProps = {
15-
defaultValue?: string;
16-
placeholder?: string;
17-
// filter: boolean | ((value: string) => boolean);
15+
// user's source of truth
16+
options: Signal<Array<Option>>;
1817
optionComponent$?: QRL<(option: any, index: number) => JSXNode>;
19-
onInputChange$?: QRL<(value: string) => void>;
2018
optionValue?: string;
2119
optionTextValue?: string;
2220
optionLabel?: string;
21+
22+
// option settings
23+
onInputChange$?: QRL<(value: string) => void>;
2324
optionValueKey?: string;
2425
optionLabelKey?: string;
2526
optionDisabledKey?: string;
26-
options: Signal<Array<Option>>;
27+
28+
// input
29+
placeholder?: string;
30+
31+
// signal binds
2732
'bind:isListboxOpenSig'?: Signal<boolean | undefined>;
2833
'bind:isInputFocusedSig'?: Signal<boolean | undefined>;
2934
'bind:isTriggerFocusedSig'?: Signal<boolean | undefined>;
@@ -34,54 +39,6 @@ export type OptionInfo = {
3439
index: number;
3540
};
3641

37-
// DO NOT REMOVE THIS: IT'S HOW YOU GET INDEXES WITHOUT MAPPING
38-
// export const Combobox: FunctionComponent<ComboboxImplProps> = (props) => {
39-
// const { children: myChildren, ...rest } = props;
40-
41-
// const childrenToProcess = (
42-
// Array.isArray(myChildren) ? [...myChildren] : [myChildren]
43-
// ) as Array<JSXNode>;
44-
45-
// // const optionsMetaData: OptionInfo[] = [];
46-
47-
// let currentIndex = 0;
48-
49-
// while (childrenToProcess.length) {
50-
// const child = childrenToProcess.shift();
51-
52-
// if (!child) {
53-
// continue;
54-
// }
55-
56-
// if (Array.isArray(child)) {
57-
// childrenToProcess.unshift(...child);
58-
// continue;
59-
// }
60-
61-
// switch (child.type) {
62-
// case ComboboxPortal: {
63-
// const portalChildren = Array.isArray(child.props.children)
64-
// ? [...child.props.children]
65-
// : [child.props.children];
66-
// childrenToProcess.unshift(...portalChildren);
67-
// break;
68-
// }
69-
// case ComboboxListbox: {
70-
// const listboxChildren = Array.isArray(child.props.children)
71-
// ? [...child.props.children]
72-
// : [child.props.children];
73-
// childrenToProcess.unshift(...listboxChildren);
74-
// break;
75-
// }
76-
// case ComboboxOption: {
77-
// child.props._index = currentIndex;
78-
// currentIndex++;
79-
// }
80-
// }
81-
// }
82-
// return <ComboboxImpl {...rest}>{props.children}</ComboboxImpl>;
83-
// };
84-
8542
import ComboboxContextId from './combobox-context-id';
8643
import { ComboboxContext } from './combobox-context.type';
8744

@@ -90,9 +47,9 @@ export const Combobox = component$((props: ComboboxProps) => {
9047
'bind:isListboxOpenSig': givenListboxOpenSig,
9148
'bind:isInputFocusedSig': givenInputFocusedSig,
9249
'bind:isTriggerFocusedSig': givenTriggerFocusedSig,
50+
options,
9351
optionComponent$,
9452
onInputChange$,
95-
options,
9653
optionValueKey,
9754
optionLabelKey,
9855
optionDisabledKey,
@@ -113,25 +70,24 @@ export const Combobox = component$((props: ComboboxProps) => {
11370
const defaultTriggerFocusedSig = useSignal<boolean | undefined>(false);
11471
const isTriggerFocusedSig = givenTriggerFocusedSig || defaultTriggerFocusedSig;
11572

116-
console.log(selectedOptionIndexSig.value);
117-
11873
const highlightedIndexSig = useSignal<number>(-1);
11974

12075
const context: ComboboxContext = {
121-
selectedOptionIndexSig,
122-
isListboxOpenSig,
123-
isInputFocusedSig,
124-
isTriggerFocusedSig,
76+
options,
77+
optionComponent$,
78+
12579
inputRef,
12680
triggerRef,
12781
listboxRef,
128-
optionComponent$,
82+
isInputFocusedSig,
83+
isTriggerFocusedSig,
84+
isListboxOpenSig,
85+
highlightedIndexSig,
86+
selectedOptionIndexSig,
12987
onInputChange$,
13088
optionValueKey,
13189
optionLabelKey,
13290
optionDisabledKey,
133-
options,
134-
highlightedIndexSig,
13591
};
13692

13793
useContextProvider(ComboboxContextId, context);

0 commit comments

Comments
 (0)