diff --git a/.changeset/gorgeous-falcons-care.md b/.changeset/gorgeous-falcons-care.md
new file mode 100644
index 000000000..abdb9eb2c
--- /dev/null
+++ b/.changeset/gorgeous-falcons-care.md
@@ -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
diff --git a/apps/website/src/routes/docs/headless/select/examples/description.tsx b/apps/website/src/routes/docs/headless/select/examples/description.tsx
new file mode 100644
index 000000000..4f4061c9c
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/select/examples/description.tsx
@@ -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 (
+
+ Logged in users
+
+
+
+ Select a user to see their profile
+
+
+ {users.map((user) => (
+
+ {user}
+
+
+
+
+ ))}
+
+
+
+ );
+});
+
+// internal
+import styles from '../snippets/select.css?inline';
+import { LuCheck } from '@qwikest/icons/lucide';
diff --git a/apps/website/src/routes/docs/headless/select/examples/validation.tsx b/apps/website/src/routes/docs/headless/select/examples/validation.tsx
index 865c4b57f..fd51df1b0 100644
--- a/apps/website/src/routes/docs/headless/select/examples/validation.tsx
+++ b/apps/website/src/routes/docs/headless/select/examples/validation.tsx
@@ -34,7 +34,11 @@ export default component$(() => {
- {field.error &&
{field.error}
}
+ {field.error && (
+
+ {field.error}
+
+ )}
{users.map((user) => (
diff --git a/apps/website/src/routes/docs/headless/select/index.mdx b/apps/website/src/routes/docs/headless/select/index.mdx
index 212ae3b6e..031b58968 100644
--- a/apps/website/src/routes/docs/headless/select/index.mdx
+++ b/apps/website/src/routes/docs/headless/select/index.mdx
@@ -175,7 +175,15 @@ The native select element is created when a form name has been given to `
-Above is an example of submitting a multi-select form.
+The `` 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.
+
+
## Component state
diff --git a/apps/website/src/routes/docs/headless/select/snippets/select.css b/apps/website/src/routes/docs/headless/select/snippets/select.css
index e5e125acd..bd124480e 100644
--- a/apps/website/src/routes/docs/headless/select/snippets/select.css
+++ b/apps/website/src/routes/docs/headless/select/snippets/select.css
@@ -28,6 +28,10 @@
outline-offset: 2px;
}
+.select-trigger[data-invalid] {
+ border: 2px dotted #d2122e;
+}
+
.select-popover {
width: 100%;
max-width: var(--select-width);
diff --git a/packages/kit-headless/src/components/accordion/accordion-root.tsx b/packages/kit-headless/src/components/accordion/accordion-root.tsx
index 63c48cfb5..32345e4f7 100644
--- a/packages/kit-headless/src/components/accordion/accordion-root.tsx
+++ b/packages/kit-headless/src/components/accordion/accordion-root.tsx
@@ -19,9 +19,12 @@ export const HAccordionRootImpl = component$((props: AccordionRootProps) => {
disabled,
collapsible = true,
animated,
+ itemsMap,
...rest
} = props;
+ itemsMap;
+
const selectedIndexSig = useSignal(initialIndex ?? -1);
const triggerRefsArray = useSignal>([]);
const isAnimatedSig = useSignal(animated === true);
diff --git a/packages/kit-headless/src/components/select/hidden-select.tsx b/packages/kit-headless/src/components/select/hidden-select.tsx
index c6336facb..56dd6625c 100644
--- a/packages/kit-headless/src/components/select/hidden-select.tsx
+++ b/packages/kit-headless/src/components/select/hidden-select.tsx
@@ -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}
diff --git a/packages/kit-headless/src/components/select/index.ts b/packages/kit-headless/src/components/select/index.ts
index d410a7532..a64a3b95d 100644
--- a/packages/kit-headless/src/components/select/index.ts
+++ b/packages/kit-headless/src/components/select/index.ts
@@ -1,7 +1,7 @@
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';
@@ -9,4 +9,6 @@ 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';
diff --git a/packages/kit-headless/src/components/select/select-context.ts b/packages/kit-headless/src/components/select/select-context.ts
index a17c659cb..a4187c50b 100644
--- a/packages/kit-headless/src/components/select/select-context.ts
+++ b/packages/kit-headless/src/components/select/select-context.ts
@@ -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;
};
export const groupContextId = createContextId('Select-Group');
diff --git a/packages/kit-headless/src/components/select/select-display-text.tsx b/packages/kit-headless/src/components/select/select-display-value.tsx
similarity index 93%
rename from packages/kit-headless/src/components/select/select-display-text.tsx
rename to packages/kit-headless/src/components/select/select-display-value.tsx
index f4048eb99..465c75fdd 100644
--- a/packages/kit-headless/src/components/select/select-display-text.tsx
+++ b/packages/kit-headless/src/components/select/select-display-value.tsx
@@ -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`;
diff --git a/packages/kit-headless/src/components/select/select-error-message.tsx b/packages/kit-headless/src/components/select/select-error-message.tsx
new file mode 100644
index 000000000..e7d8c9cf6
--- /dev/null
+++ b/packages/kit-headless/src/components/select/select-error-message.tsx
@@ -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 (
+
+
+
+ );
+});
diff --git a/packages/kit-headless/src/components/select/select-inline.tsx b/packages/kit-headless/src/components/select/select-inline.tsx
index 3583c1b8b..a30ca94e1 100644
--- a/packages/kit-headless/src/components/select/select-inline.tsx
+++ b/packages/kit-headless/src/components/select/select-inline.tsx
@@ -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;
};
/*
@@ -22,6 +24,7 @@ export const HSelectRoot: Component = (
selectLabelComponent: UserLabel,
selectItemComponent: UserItem,
selectItemLabelComponent: UserItemLabel,
+ selectErrorMessageComponent: UserErrorMessage,
...rest
} = props;
@@ -31,6 +34,7 @@ export const HSelectRoot: Component = (
const SelectLabel = UserLabel ?? InternalSelectLabel;
const SelectItem = UserItem ?? InternalSelectItem;
const SelectItemLabel = UserItemLabel ?? InternalSelectItemLabel;
+ const SelectErrorMessage = UserErrorMessage ?? InternalSelectErrorMessage;
// source of truth
const itemsMap = new Map();
@@ -40,6 +44,7 @@ export const HSelectRoot: Component = (
let valuePropIndex = null;
let isLabelNeeded = false;
+ let isInvalid = false;
const childrenToProcess = (
Array.isArray(myChildren) ? [...myChildren] : [myChildren]
@@ -119,6 +124,12 @@ export const HSelectRoot: Component = (
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)
@@ -138,6 +149,7 @@ export const HSelectRoot: Component = (
_label={isLabelNeeded}
_valuePropIndex={valuePropIndex}
_itemsMap={itemsMap}
+ invalid={isInvalid}
>
{props.children}
diff --git a/packages/kit-headless/src/components/select/select-listbox.tsx b/packages/kit-headless/src/components/select/select-listbox.tsx
index 3f339c6eb..571d3e165 100644
--- a/packages/kit-headless/src/components/select/select-listbox.tsx
+++ b/packages/kit-headless/src/components/select/select-listbox.tsx
@@ -67,6 +67,7 @@ export const HSelectListbox = component$((props) => {
ref={context.listboxRef}
data-open={context.isListboxOpenSig.value ? '' : undefined}
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
+ data-invalid={context.isInvalidSig?.value ? '' : undefined}
>
diff --git a/packages/kit-headless/src/components/select/select-popover.tsx b/packages/kit-headless/src/components/select/select-popover.tsx
index 6005f125c..3f4ec6ab2 100644
--- a/packages/kit-headless/src/components/select/select-popover.tsx
+++ b/packages/kit-headless/src/components/select/select-popover.tsx
@@ -51,6 +51,7 @@ export const HSelectPopover = component$>((props) =
diff --git a/packages/kit-headless/src/components/select/select-root.tsx b/packages/kit-headless/src/components/select/select-root.tsx
index 764b88e72..1b0e27899 100644
--- a/packages/kit-headless/src/components/select/select-root.tsx
+++ b/packages/kit-headless/src/components/select/select-root.tsx
@@ -105,6 +105,8 @@ export type SelectProps = Omit<
* If `true`, allows multiple selections.
*/
multiple?: M;
+
+ invalid?: boolean;
} & TMultiValue &
TStringOrArray;
@@ -123,9 +125,12 @@ export const HSelectImpl = component$ & InternalSelectProps
name,
required,
disabled,
+ invalid,
...rest
} = props;
+ invalid;
+
// refs
const rootRef = useSignal();
const triggerRef = useSignal();
@@ -163,6 +168,18 @@ export const HSelectImpl = component$ & InternalSelectProps
const initialLoadSig = useSignal(true);
const highlightedItemRef = useSignal();
const isDisabledSig = useSignal(disabled ?? false);
+ const isInvalidSig = useSignal(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,
@@ -183,6 +200,7 @@ export const HSelectImpl = component$ & InternalSelectProps
name,
required,
isDisabledSig,
+ isInvalidSig,
};
useContextProvider(SelectContextId, context);
@@ -292,6 +310,8 @@ export const HSelectImpl = component$ & 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"
diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx
index dd8267022..52ffb5f79 100644
--- a/packages/kit-headless/src/components/select/select-trigger.tsx
+++ b/packages/kit-headless/src/components/select/select-trigger.tsx
@@ -17,6 +17,8 @@ export const HSelectTrigger = component$((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();
@@ -112,16 +114,18 @@ export const HSelectTrigger = component$((props) => {
return (