@jpgilldev / steid

steid/src/components/select.rs
5.6 KBRaw
1use topcoat::{
2 Result,
3 context::Cx,
4 icon::{IconData, icon, iconify::iconify_icon},
5 view::{Attributes, View, attributes, class, component, view},
6};
7
8/// The classes for the native `<select>` inside the [`select`] component.
9///
10/// Sized to match the input control. The native dropdown arrow is suppressed
11/// so the component can draw its own chevron, which keeps the control looking
12/// the same across browsers; the extra right padding reserves the chevron's
13/// space.
14const SELECT: &str = "h-9 w-full appearance-none items-center rounded-lg border border-border \
15 bg-background pr-8 pl-3 text-left text-sm shadow-xs transition-colors outline-none \
16 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \
17 focus-visible:ring-offset-background disabled:pointer-events-none";
18
19/// The classes restyling the drop-down picker, for browsers that support
20/// customizable selects (`appearance: base-select`, set on the `<select>` by
21/// the component's wrapper).
22///
23/// The panel and its option rows take after the dropdown menu's content and
24/// items: the same raised surface, the same ghost-tinted hover and focus
25/// states, and the checked option marked by a checkmark on the row's right
26/// edge: the [`CHECKMARK`] icon, masked over the theme's muted foreground
27/// (see [`checkmark_style`]). The browser's own picker icon is hidden in
28/// favor of the component's chevron. On browsers without support every rule
29/// here is inert and the operating system's picker shows instead.
30const PICKER: &str = "[&::picker(select)]:[appearance:base-select] \
31 [&::picker(select)]:mt-1 [&::picker(select)]:rounded-lg \
32 [&::picker(select)]:border [&::picker(select)]:border-border \
33 [&::picker(select)]:bg-background [&::picker(select)]:p-1 \
34 [&::picker(select)]:text-foreground [&::picker(select)]:shadow-sm \
35 [&::picker-icon]:hidden \
36 [&_option]:flex [&_option]:items-center [&_option]:gap-2 [&_option]:rounded-md \
37 [&_option]:px-2 [&_option]:py-1.5 [&_option]:text-sm [&_option]:outline-none \
38 [&_option:hover]:bg-foreground/5 [&_option:focus]:bg-foreground/5 \
39 [&_option:checked]:font-medium \
40 [&_option::checkmark]:order-1 [&_option::checkmark]:ml-auto \
41 [&_option::checkmark]:size-4 [&_option::checkmark]:shrink-0 \
42 [&_option::checkmark]:content-[''] [&_option::checkmark]:bg-muted-foreground \
43 [&_option::checkmark]:[mask-size:100%_100%] \
44 [&_option::checkmark]:[mask-image:var(--select-checkmark)]";
45
46/// The icon marking the picker's checked option.
47const CHECKMARK: IconData = iconify_icon!("feather:check");
48
49/// The inline style for the [`select`] wrapper, carrying [`CHECKMARK`] as a
50/// data URI in the `--select-checkmark` custom property. The indirection
51/// exists because the `::checkmark` pseudo-element can only take the icon
52/// through a stylesheet, as a mask image, while the icon's markup is only
53/// available here.
54fn checkmark_style(cx: &Cx) -> String {
55 let svg = format!(
56 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}">{}</svg>"#,
57 CHECKMARK.view_box(),
58 CHECKMARK.into_body().render(cx),
59 );
60 let mut style = String::from(r#"--select-checkmark: url("data:image/svg+xml,"#);
61 // Percent-encode the characters that cannot appear in a double-quoted
62 // CSS url().
63 for char in svg.chars() {
64 match char {
65 '%' => style.push_str("%25"),
66 '"' => style.push_str("%22"),
67 '#' => style.push_str("%23"),
68 _ => style.push(char),
69 }
70 }
71 style.push_str(r#"")"#);
72 style
73}
74
75/// A select component: a themed native `<select>`.
76///
77/// Child nodes become the `<select>`'s content, typically `<option>` and
78/// `<optgroup>` elements. The `attrs` (such as `name`, `disabled`, or event
79/// handlers) are forwarded to the `<select>`; a `class` among them is appended
80/// to the wrapping element's classes, so width utilities size the whole
81/// control. Like the input, it fills its container by default.
82///
83/// On browsers with customizable select support the drop-down picker is
84/// restyled to match the dropdown menu component, and the chevron flips while
85/// it is open; other browsers keep the operating system's picker. The control
86/// itself looks the same everywhere.
87///
88/// ```ignore
89/// view! {
90/// select(
91/// attrs: attributes! { name="region" },
92/// <option>"eu-central-1"</option>
93/// <option>"us-east-1"</option>
94/// )
95/// }
96/// ```
97#[component]
98pub async fn select(cx: &Cx, #[default] mut attrs: Attributes, #[default] child: View) -> Result {
99 // `appearance: base-select` opts into the customizable picker. It is set
100 // from the wrapper because the descendant selector outranks the
101 // `appearance-none` fallback in specificity, making the outcome
102 // independent of stylesheet order; browsers without support drop the
103 // invalid declaration and keep the fallback.
104 view! {
105 <span
106 class=(class!(
107 "relative block has-[:disabled]:opacity-50 \
108 [&>select]:[appearance:base-select] \
109 [&:has(select:open)>svg]:rotate-180",
110 attrs.remove("class"),
111 ))
112 style=(checkmark_style(cx))
113 >
114 <select class=(class!(SELECT, PICKER)) (attrs)>(child)</select>
115 icon(
116 data: iconify_icon!("feather:chevron-down"),
117 attrs: attributes! {
118 class="pointer-events-none absolute top-1/2 right-3 size-4 \
119 -translate-y-1/2 text-muted-foreground transition-transform"
120 }
121 )
122 </span>
123 }
124}