Skip to content

Commit

Permalink
Merge pull request #825 from thejackshelton/select-improvements
Browse files Browse the repository at this point in the history
export select description
  • Loading branch information
thejackshelton authored Jun 5, 2024
2 parents 2b60e47 + 427ea3a commit 0968eae
Show file tree
Hide file tree
Showing 17 changed files with 130 additions and 6 deletions.
9 changes: 9 additions & 0 deletions .changeset/gorgeous-falcons-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@qwik-ui/headless': patch
---

feat: new Select.ErrorMessage component

feat: data-invalid attribute to style when the select is invalid

feat: new Select.Description component
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { component$, useStyles$ } from '@builder.io/qwik';
import { Select } from '@qwik-ui/headless';

export default component$(() => {
useStyles$(styles);
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];

return (
<Select.Root class="select">
<Select.Label>Logged in users</Select.Label>
<Select.Trigger class="select-trigger">
<Select.DisplayValue placeholder="Select an option" />
</Select.Trigger>
<Select.Description>Select a user to see their profile</Select.Description>
<Select.Popover class="select-popover">
<Select.Listbox class="select-listbox">
{users.map((user) => (
<Select.Item class="select-item" key={user}>
<Select.ItemLabel>{user}</Select.ItemLabel>
<Select.ItemIndicator>
<LuCheck />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Listbox>
</Select.Popover>
</Select.Root>
);
});

// internal
import styles from '../snippets/select.css?inline';
import { LuCheck } from '@qwikest/icons/lucide';
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export default component$(() => {
<Select.Trigger class="select-trigger">
<Select.DisplayValue placeholder="Select an option" />
</Select.Trigger>
{field.error && <div style={{ color: '#D2122E' }}>{field.error}</div>}
{field.error && (
<Select.ErrorMessage style={{ color: '#D2122E' }}>
{field.error}
</Select.ErrorMessage>
)}
<Select.Popover class="select-popover">
<Select.Listbox class="select-listbox">
{users.map((user) => (
Expand Down
10 changes: 9 additions & 1 deletion apps/website/src/routes/docs/headless/select/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,15 @@ The native select element is created when a form name has been given to `<Select

<Showcase name="validation" />

Above is an example of submitting a multi-select form.
The `<Select.ErrorMessage />` component is used to display errors when the select is invalid.

To style based on the invalid state, use the `data-invalid` data attribute.

### Descriptions

Provide more information to assistive technologies by adding a description to the select.

<Showcase name="description" />

## Component state

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
outline-offset: 2px;
}

.select-trigger[data-invalid] {
border: 2px dotted #d2122e;
}

.select-popover {
width: 100%;
max-width: var(--select-width);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ export const HAccordionRootImpl = component$((props: AccordionRootProps) => {
disabled,
collapsible = true,
animated,
itemsMap,
...rest
} = props;

itemsMap;

const selectedIndexSig = useSignal<number>(initialIndex ?? -1);
const triggerRefsArray = useSignal<Array<Signal>>([]);
const isAnimatedSig = useSignal<boolean>(animated === true);
Expand Down
4 changes: 4 additions & 0 deletions packages/kit-headless/src/components/select/hidden-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const HHiddenNativeSelect = component$(
// @ts-expect-error modular forms ref function
ref?.(element);
}}
onFocus$={() => {
// override modular forms focus event
return;
}}
multiple={context.multiple}
tabIndex={-1}
autocomplete={autoComplete}
Expand Down
4 changes: 3 additions & 1 deletion packages/kit-headless/src/components/select/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
export { HSelectRoot as Root } from './select-inline';
export { HSelectLabel as Label } from './select-label';
export { HSelectTrigger as Trigger } from './select-trigger';
export { HSelectDisplayText as DisplayValue } from './select-display-text';
export { HSelectDisplayValue as DisplayValue } from './select-display-value';
export { HSelectPopover as Popover } from './select-popover';
export { HSelectListbox as Listbox } from './select-listbox';
export { HSelectGroup as Group } from './select-group';
export { HSelectGroupLabel as GroupLabel } from './select-group-label';
export { HSelectItem as Item } from './select-item';
export { HSelectItemLabel as ItemLabel } from './select-item-label';
export { HSelectItemIndicator as ItemIndicator } from './select-item-indicator';
export { HSelectDescription as Description } from './select-description';
export { HHiddenNativeSelect as HiddenNativeSelect } from './hidden-select';
export { HSelectErrorMessage as ErrorMessage } from './select-error-message';
2 changes: 2 additions & 0 deletions packages/kit-headless/src/components/select/select-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export type SelectContext = {
* Specifies that the user must select a value before submitting the form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#required).
*/
required?: boolean;

isInvalidSig?: Signal<boolean>;
};

export const groupContextId = createContextId<GroupContext>('Select-Group');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type SelectValueProps = PropsOf<'span'> & {
placeholder?: string;
};

export const HSelectDisplayText = component$((props: SelectValueProps) => {
export const HSelectDisplayValue = component$((props: SelectValueProps) => {
const { placeholder, ...rest } = props;
const context = useContext(SelectContextId);
const valueId = `${context.localId}-value`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PropsOf, Slot, component$, useContext } from '@builder.io/qwik';
import SelectContextId from './select-context';

export const HSelectErrorMessage = component$((props: PropsOf<'div'>) => {
const context = useContext(SelectContextId);
const errorMessageId = `${context.localId}-error-message`;

return (
<div role="alert" id={errorMessageId} {...props}>
<Slot />
</div>
);
});
12 changes: 12 additions & 0 deletions packages/kit-headless/src/components/select/select-inline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { HSelectImpl, type SelectProps } from './select-root';
import { HSelectItem as InternalSelectItem } from './select-item';
import { HSelectLabel as InternalSelectLabel } from './select-label';
import { HSelectItemLabel as InternalSelectItemLabel } from './select-item-label';
import { HSelectErrorMessage as InternalSelectErrorMessage } from './select-error-message';

type InlineCompProps = {
selectLabelComponent?: typeof InternalSelectLabel;
selectItemComponent?: typeof InternalSelectItem;
selectItemLabelComponent?: typeof InternalSelectItemLabel;
selectErrorMessageComponent?: typeof InternalSelectErrorMessage;
};

/*
Expand All @@ -22,6 +24,7 @@ export const HSelectRoot: Component<SelectProps & InlineCompProps> = (
selectLabelComponent: UserLabel,
selectItemComponent: UserItem,
selectItemLabelComponent: UserItemLabel,
selectErrorMessageComponent: UserErrorMessage,
...rest
} = props;

Expand All @@ -31,6 +34,7 @@ export const HSelectRoot: Component<SelectProps & InlineCompProps> = (
const SelectLabel = UserLabel ?? InternalSelectLabel;
const SelectItem = UserItem ?? InternalSelectItem;
const SelectItemLabel = UserItemLabel ?? InternalSelectItemLabel;
const SelectErrorMessage = UserErrorMessage ?? InternalSelectErrorMessage;

// source of truth
const itemsMap = new Map();
Expand All @@ -40,6 +44,7 @@ export const HSelectRoot: Component<SelectProps & InlineCompProps> = (

let valuePropIndex = null;
let isLabelNeeded = false;
let isInvalid = false;

const childrenToProcess = (
Array.isArray(myChildren) ? [...myChildren] : [myChildren]
Expand Down Expand Up @@ -119,6 +124,12 @@ export const HSelectRoot: Component<SelectProps & InlineCompProps> = (
break;
}

case SelectErrorMessage: {
// when the component is present in the JSX, it's invalid
isInvalid = true;
break;
}

default: {
if (child) {
const anyChildren = Array.isArray(child.children)
Expand All @@ -138,6 +149,7 @@ export const HSelectRoot: Component<SelectProps & InlineCompProps> = (
_label={isLabelNeeded}
_valuePropIndex={valuePropIndex}
_itemsMap={itemsMap}
invalid={isInvalid}
>
{props.children}
</HSelectImpl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const HSelectListbox = component$<SelectListboxProps>((props) => {
ref={context.listboxRef}
data-open={context.isListboxOpenSig.value ? '' : undefined}
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
data-invalid={context.isInvalidSig?.value ? '' : undefined}
>
<Slot />
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const HSelectPopover = component$<PropsOf<typeof HPopoverRoot>>((props) =
<HPopoverPanel
data-open={context.isListboxOpenSig.value ? '' : undefined}
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
data-invalid={context.isInvalidSig?.value ? '' : undefined}
{...rest}
>
<Slot />
Expand Down
20 changes: 20 additions & 0 deletions packages/kit-headless/src/components/select/select-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export type SelectProps<M extends boolean = boolean> = Omit<
* If `true`, allows multiple selections.
*/
multiple?: M;

invalid?: boolean;
} & TMultiValue &
TStringOrArray;

Expand All @@ -123,9 +125,12 @@ export const HSelectImpl = component$<SelectProps<boolean> & InternalSelectProps
name,
required,
disabled,
invalid,
...rest
} = props;

invalid;

// refs
const rootRef = useSignal<HTMLDivElement>();
const triggerRef = useSignal<HTMLButtonElement>();
Expand Down Expand Up @@ -163,6 +168,18 @@ export const HSelectImpl = component$<SelectProps<boolean> & InternalSelectProps
const initialLoadSig = useSignal<boolean>(true);
const highlightedItemRef = useSignal<HTMLLIElement>();
const isDisabledSig = useSignal<boolean>(disabled ?? false);
const isInvalidSig = useSignal<boolean>(props.invalid ?? false);

useTask$(({ track }) => {
/**
* We want the component to be invalid based on the presence of the Select.ErrorMessage component. If passed through context as just a prop that won't work.
*
* my guess here, is props.invalid is a store under the hood, so it can track changes to the property, but when destructured, it's just a
*
* So we update a signal here so that the context can track it.
*/
isInvalidSig.value = track(() => props.invalid ?? false);
});

const context: SelectContext = {
itemsMapSig,
Expand All @@ -183,6 +200,7 @@ export const HSelectImpl = component$<SelectProps<boolean> & InternalSelectProps
name,
required,
isDisabledSig,
isInvalidSig,
};

useContextProvider(SelectContextId, context);
Expand Down Expand Up @@ -292,6 +310,8 @@ export const HSelectImpl = component$<SelectProps<boolean> & InternalSelectProps
data-open={context.isListboxOpenSig.value ? '' : undefined}
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
data-disabled={isDisabledSig.value ? '' : undefined}
data-invalid={context.isInvalidSig?.value ? '' : undefined}
aria-invalid={context.isInvalidSig?.value}
aria-controls={listboxId}
aria-expanded={context.isListboxOpenSig.value}
aria-haspopup="listbox"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const HSelectTrigger = component$<SelectTriggerProps>((props) => {
useSelect();
const labelId = `${context.localId}-label`;
const descriptionId = `${context.localId}-description`;
const errorMessageId = `${context.localId}-error-message`;
const triggerId = `${context.localId}-trigger`;
const initialKeyDownSig = useSignal(true);
const { typeahead$ } = useTypeahead();

Expand Down Expand Up @@ -112,16 +114,18 @@ export const HSelectTrigger = component$<SelectTriggerProps>((props) => {
return (
<button
{...props}
id={`${context.localId}-trigger`}
id={triggerId}
ref={context.triggerRef}
onClick$={[handleClickSync$, handleClick$, props.onClick$]}
onKeyDown$={[handleKeyDownSync$, handleKeyDown$, props.onKeyDown$]}
data-open={context.isListboxOpenSig.value ? '' : undefined}
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
data-disabled={context.isDisabledSig.value ? '' : undefined}
data-invalid={context.isInvalidSig?.value ? '' : undefined}
aria-expanded={context.isListboxOpenSig.value}
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-describedby={`${descriptionId}
${errorMessageId}`}
disabled={context.isDisabledSig.value ? true : undefined}
preventdefault:blur
>
Expand Down
4 changes: 4 additions & 0 deletions packages/kit-styled/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const Root = (props: PropsOf<typeof HeadlessSelect.Root>) => (
selectItemComponent={Item}
selectItemLabelComponent={ItemLabel}
selectLabelComponent={Label}
selectErrorMessageComponent={ErrorMessage}
/>
);

Expand Down Expand Up @@ -82,6 +83,8 @@ const Group = HeadlessSelect.Group;

const GroupLabel = HeadlessSelect.GroupLabel;

const ErrorMessage = HeadlessSelect.ErrorMessage;

const Item = component$<PropsOf<typeof HeadlessSelect.Item>>(({ ...props }) => {
return (
<HeadlessSelect.Item
Expand Down Expand Up @@ -129,4 +132,5 @@ export const Select = {
Item,
ItemIndicator,
ItemLabel,
ErrorMessage,
};

0 comments on commit 0968eae

Please sign in to comment.