Select
מעטפת לאלמנט select המובנה.
- דפדפנים
- Chrome 135+Firefox 121+למה הגרסה הזאת?Safari 17+
Firefox keeps the native select control. This custom styling uses
::picker(select),:open, andoption::checkmark, which Firefox does not support yet.למה הגרסה הזאת?Safari keeps the native select control. This custom styling uses
::picker(select),:open, andoption::checkmark, which Safari does not support yet. - שכבה
- 0
- גודל חבילה דחוסה
- 535 B
- מגבלת פריסה כפויה
- 0 ms
- מגבלת Axe
- 0
- תואם SSR
- כן
- חוזה מפורסם
- סביבת בדיקות
מגרש ניסויים
import { Select } from "@gunkin/uiify";
export default function Example() {
return (
<Select.Root className="" name="plan" aria-label="Plan" defaultValue="free">
<Select.Content>
<Select.Item className="" value="free">
Free
</Select.Item>
<Select.Item className="" value="pro">
Pro
</Select.Item>
</Select.Content>
</Select.Root>
);
}@layer components {
[data-uiify-select] {
inline-size: 100%;
/* base-select sizes the closed control to its selected content rather than
to the widest option, so the control would resize as the value changes.
A floor keeps it stable. */
min-inline-size: 12rem;
max-inline-size: 16rem;
display: flex;
align-items: center;
gap: var(--space-md);
box-sizing: border-box;
block-size: var(--uiify-control-size, var(--control-md));
padding-block: 0;
padding-inline: var(--space-lg);
border: var(--border-width) solid var(--input);
border-radius: var(--radius-full);
background-color: var(--card);
color: var(--foreground);
font-size: var(--uiify-control-font, var(--text-base));
line-height: var(--leading-normal);
cursor: pointer;
transition: var(--transition-colors);
}
[data-uiify-select]:focus-visible {
border-color: var(--primary);
}
[data-uiify-select]:disabled,
[data-uiify-select][data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* Chevron — a border-corner mark, same technique as the accordion's
disclosure triangle, replacing the browser's default picker-icon glyph
(which renders as a crude filled triangle with no theming hook). */
[data-uiify-select]::picker-icon {
content: "";
display: inline-block;
inline-size: 0.3rem;
block-size: 0.3rem;
/* Safari's user-agent sheet pads ::picker-icon, which stretched the corner into a
chevron about twice Chromium's size. */
padding: 0;
margin-block-end: 0.1rem;
margin-inline-start: auto;
border-inline-end: 2px solid var(--muted-foreground);
border-block-end: 2px solid var(--muted-foreground);
rotate: 45deg;
transition: rotate var(--duration-fast) var(--ease-out);
}
[data-uiify-select]:open::picker-icon {
margin-block-end: -0.2rem;
rotate: 225deg;
}
/* Picker surface — matches [data-uiify-menu] in menu.css. `inset-block-start` is set
explicitly with an anchor() offset, not `margin-block-start`: this box
is already anchor-positioned by the browser's implicit select/picker
association, and a plain margin on it is accepted (computed style
reflects it) but has no visible effect. The shared native geometry lives
in behavior.css; this picker keeps its own explicit gap. */
[data-uiify-select]::picker(select) {
min-inline-size: 12rem;
max-block-size: min(24rem, calc(100dvb - 2rem));
padding: var(--space-xs);
border: var(--border-width) solid var(--border);
border-radius: var(--radius-lg);
background-color: var(--popover);
color: var(--popover-foreground);
box-shadow: var(--shadow-popover);
overscroll-behavior: contain;
inset-block-start: calc(anchor(bottom) + var(--space-sm));
}
@media (prefers-reduced-motion: no-preference) {
[data-uiify-select]::picker(select) {
transition: var(--transition-presence);
}
[data-uiify-select]:not(:open)::picker(select) {
opacity: 0;
scale: var(--presence-scale);
}
@starting-style {
[data-uiify-select]:open::picker(select) {
opacity: 0;
scale: var(--presence-scale);
}
}
}
/* Group label — matches [data-uiify-menu][data-part="label"], including its padding-inline:
var(--space-md) inset. <option> is a real DOM child of <optgroup>, so
that same inline padding would otherwise stack with option's own
padding-inline and double-indent every grouped item (measured directly:
+12px versus an ungrouped option) — the option rule just below cancels
it back out with an equal negative margin plus a matching width bump,
restoring the same effective inset ungrouped options have. */
[data-uiify-select] optgroup {
padding-block-start: var(--space-xs);
padding-inline: var(--space-md);
color: var(--muted-foreground);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
[data-uiify-select] optgroup option {
margin-inline: calc(-1 * var(--space-md));
inline-size: calc(100% + 2 * var(--space-md));
}
/* Option — matches [data-uiify-menu][data-part="item"]. `color` is set explicitly, not left
to inherit: a grouped <option> is a DOM child of the muted, semibold
<optgroup> above, and CSS inheritance would otherwise hand it that same
muted color (font-weight is unaffected only because the browser's own
UA stylesheet resets it on <option> directly, which beats inheritance;
color has no such reset). */
[data-uiify-select] option {
display: flex;
align-items: center;
column-gap: var(--space-md);
padding-block: var(--space-sm);
padding-inline: var(--space-md);
border-radius: calc(var(--radius-lg) - var(--space-xs));
color: var(--foreground);
cursor: pointer;
font-size: var(--text-base) !important;
}
/* :focus-visible, not :focus: the browser gives the currently-selected
option real DOM focus the instant the picker opens (a11y default — a
keyboard/screen-reader user should land on their current value), so
:focus alone made the selected row look permanently "hovered" even
before the pointer touched anything, indistinguishable from actually
hovering a different row. :focus-visible's browser heuristic only
matches once the user actually drives focus with the keyboard, leaving
an untouched selection to show nothing but its checkmark. */
[data-uiify-select] option:hover,
[data-uiify-select] option:focus-visible {
background-color: var(--accent);
color: var(--accent-foreground);
}
[data-uiify-select] option:disabled {
opacity: 0.5;
pointer-events: none;
}
/* Selection marker — matches the menu's checkbox item-indicator (filled
accent square, not the radio dot): ::checkmark only ever exists on the
selected option, so there's no separate "unchecked" state to draw. The
check is punched out of the square with an even-odd clip-path polygon
(the zero-width bridge from the corner to the check cancels itself)
instead of typeset as a glyph: U+2713 falls back to a different font in
each engine, and in Safari it sat above and left of centre. The polygon is
the Checkbox checkmark's, placed in the same 1rem inset. The hole shows the
popover surface, which is what the theme's --primary-foreground already
contrasts with. */
[data-uiify-select] option::checkmark {
order: -1;
box-sizing: border-box;
inline-size: 1.25rem;
block-size: 1.25rem;
flex: 0 0 1.25rem;
display: block;
border-radius: var(--radius-xs);
background: var(--primary);
content: "";
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
32.72% 47.12%,
26% 57.2%,
44.24% 74%,
74% 36.56%,
66.32% 28.88%,
44.24% 58.64%,
32.72% 47.12%
);
}
[data-uiify-select] hr {
margin-block: var(--space-xs);
border: 0;
border-block-start: var(--border-width) solid var(--border);
}
@media (prefers-reduced-motion: reduce) {
[data-uiify-select]::picker-icon,
[data-uiify-select]::picker(select) {
transition: none;
}
}
}import { Select } from "@gunkin/uiify";
export default function Example() {
return (
<Select.Root
className="
[&[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
[&_[data-positioning=native]]:[--uiify-anchor-offset:var(--space-sm)]
[&[popover]:popover-open:not([data-positioning=native])]:[margin:revert]
[&_[popover]:popover-open:not([data-positioning=native])]:[margin:revert] focus-visible:[outline:none]
focus-visible:[box-shadow:0_0_0_var(--ring-width)_color-mix(in_oklch,_var(--ring)_20%,_transparent)]
[inline-size:100%] [min-inline-size:12rem] [max-inline-size:16rem] flex items-center gap-md box-border
[block-size:var(--control-md)] py-0 px-lg border-[length:var(--border-width)] border-solid border-input
rounded-full bg-card text-foreground [font-size:var(--text-base)] [line-height:var(--leading-normal)]
cursor-pointer [transition:var(--transition-colors)] focus-visible:border-primary disabled:opacity-50
disabled:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed
[&::picker-icon]:content-[''] [&::picker-icon]:inline-block [&::picker-icon]:[inline-size:0.3rem]
[&::picker-icon]:[block-size:0.3rem] [&::picker-icon]:p-0 [&::picker-icon]:[margin-block-end:0.1rem]
[&::picker-icon]:[margin-inline-start:auto] [&::picker-icon]:[border-inline-end-width:2px]
[&::picker-icon]:[border-inline-end-style:solid]
[&::picker-icon]:[border-inline-end-color:var(--muted-foreground)] [&::picker-icon]:[border-block-end-width:2px]
[&::picker-icon]:[border-block-end-style:solid]
[&::picker-icon]:[border-block-end-color:var(--muted-foreground)] [&::picker-icon]:[rotate:45deg]
[&::picker-icon]:[transition:rotate_var(--duration-fast)_var(--ease-out)]
[&:open::picker-icon]:[margin-block-end:-0.2rem] [&:open::picker-icon]:[rotate:225deg]
[&::picker(select)]:[min-inline-size:12rem] [&::picker(select)]:[max-block-size:min(24rem,_calc(100dvb_-_2rem))]
[&::picker(select)]:p-xs [&::picker(select)]:border-[length:var(--border-width)]
[&::picker(select)]:border-solid [&::picker(select)]:border-border [&::picker(select)]:rounded-lg
[&::picker(select)]:bg-popover [&::picker(select)]:text-popover-foreground [&::picker(select)]:shadow-popover
[&::picker(select)]:overscroll-contain
[&::picker(select)]:[inset-block-start:calc(anchor(bottom)_+_var(--space-sm))]
motion-safe:[&::picker(select)]:[transition:var(--transition-presence)]
motion-safe:[&:not(:open)::picker(select)]:opacity-0
motion-safe:[&:not(:open)::picker(select)]:[scale:var(--presence-scale)]
motion-safe:starting:[&:open::picker(select)]:opacity-0
motion-safe:starting:[&:open::picker(select)]:[scale:var(--presence-scale)]
[&_optgroup_option]:[margin-inline:calc(-1_*_var(--space-md))]
[&_optgroup_option]:[inline-size:calc(100%_+_2_*_var(--space-md))]
motion-reduce:[&::picker-icon]:[transition:none] motion-reduce:[&::picker(select)]:[transition:none]
"
name="plan"
aria-label="Plan"
defaultValue="free"
>
<Select.Content>
<Select.Item
className={
"flex items-center gap-x-md py-sm px-md [border-radius:calc(var(--radius-lg)_-_var(--space-xs))] text-fo" +
"reground cursor-pointer text-base! hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent" +
" focus-visible:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none [&::checkmark]:[" +
"order:-1] [&::checkmark]:box-border [&::checkmark]:[inline-size:1.25rem] [&::checkmark]:[block-size:1.2" +
"5rem] [&::checkmark]:[flex:0_0_1.25rem] [&::checkmark]:block [&::checkmark]:rounded-xs [&::checkmark]:b" +
"g-primary [&::checkmark]:content-[''] [&::checkmark]:[clip-path:polygon(evenodd,0_0,100%_0,100%_100%,0_" +
"100%,0_0,32.72%_47.12%,26%_57.2%,44.24%_74%,74%_36.56%,66.32%_28.88%,44.24%_58.64%,32.72%_47.12%)]"
}
value="free"
>
Free
</Select.Item>
<Select.Item
className={
"flex items-center gap-x-md py-sm px-md [border-radius:calc(var(--radius-lg)_-_var(--space-xs))] text-fo" +
"reground cursor-pointer text-base! hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent" +
" focus-visible:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none [&::checkmark]:[" +
"order:-1] [&::checkmark]:box-border [&::checkmark]:[inline-size:1.25rem] [&::checkmark]:[block-size:1.2" +
"5rem] [&::checkmark]:[flex:0_0_1.25rem] [&::checkmark]:block [&::checkmark]:rounded-xs [&::checkmark]:b" +
"g-primary [&::checkmark]:content-[''] [&::checkmark]:[clip-path:polygon(evenodd,0_0,100%_0,100%_100%,0_" +
"100%,0_0,32.72%_47.12%,26%_57.2%,44.24%_74%,74%_36.56%,66.32%_28.88%,44.24%_58.64%,32.72%_47.12%)]"
}
value="pro"
>
Pro
</Select.Item>
</Select.Content>
</Select.Root>
);
}API
Select.Root
| מאפיין | טיפוס | ברירת מחדל | תיאור |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | גובה הפקד וגודל הטקסט, בהתאמה ל-Button (32, 40 או 48 px); נכתבים ל-data-size, עם md כשלא הוגדר ערך. |
children | ReactNode | — | חלקי Select, בדרך כלל Select.Content המכיל פריטים וקבוצות. |
value | string | — | הערך הנבחר, ההופך את תיבת הבחירה למבוקרת. |
defaultValue | string | — | הערך הנבחר בהצגה הראשונה; מכאן והלאה תיבת הבחירה המובנית מנהלת את הבחירה, ולכן היא פועלת ללא JavaScript. |
onValueChange | (value: string) => void | — | נקראת עם הערך החדש כשהבחירה משתנה. |
name | string | — | שם שדה הטופס הנשלח עם הערך הנבחר. |
disabled | boolean | false | משבית את תיבת הבחירה המובנית ומגדיר את המאפיין data-disabled. |
render | ReactElement | ((props, state) => ReactElement) | — | החלפת האלמנט המוצג תוך שימור ההתנהגות וקישורי הנגישות. |
Select.Content
| מאפיין | טיפוס | ברירת מחדל | תיאור |
|---|---|---|---|
children | ReactNode | — | פריטים, קבוצות ומפרידים; החלק מציג את ילדיו ללא הוספת אלמנט. |
Select.Item
| מאפיין | טיפוס | ברירת מחדל | תיאור |
|---|---|---|---|
children | ReactNode | — | הטקסט המוצג לאפשרות זו. |
disabled | boolean | false | מונע בחירה באפשרות זו. |
value* | string | — | הערך הנשלח כשאפשרות זו נבחרת. |
render | ReactElement | ((props, state) => ReactElement) | — | החלפת האלמנט המוצג תוך שימור ההתנהגות וקישורי הנגישות. |
Select.Group
| מאפיין | טיפוס | ברירת מחדל | תיאור |
|---|---|---|---|
children | ReactNode | — | הפריטים השייכים לקבוצה זו. |
label* | string | — | כותרת הקבוצה המוצגת על ידי קבוצת האפשרויות המובנית. |
render | ReactElement | ((props, state) => ReactElement) | — | החלפת האלמנט המוצג תוך שימור ההתנהגות וקישורי הנגישות. |
Select.Separator
| מאפיין | טיפוס | ברירת מחדל | תיאור |
|---|---|---|---|
render | ReactElement | ((props, state) => ReactElement) | — | החלפת האלמנט המוצג תוך שימור ההתנהגות וקישורי הנגישות. |
מקלדת
| מקש | פעולה |
|---|---|
| Arrow keys | שינוי האפשרות בעת מיקוד |
| Space | פתיחת הבורר המובנה (תלוי בפלטפורמה) |
נגישות
אלמנט <select> מובנה עם טקסט בלבד; הדפדפן אחראי לבחירה, לקלט מקלדת, למעבר בהקלדה ולשליחת הטופס. מנועים תומכים משפרים רק את מראה הבורר.
נקודות חיבור לעיצוב
data-disabled