Skip to main content

tablo_ui/components/primitives/
select.rs

1// SYNC: topcoat-ui-registry@0.11.0 sha256:044fb72b888ef18e16ed17feeb495010f9aaad8fde8b0933ff750e66d612c9ff — do not hand-edit. Sync via `cargo xtask sync-topcoat-ui` (ADR-0007).
2use topcoat::{
3    Result,
4    context::Cx,
5    icon::{IconData, icon, iconify::iconify_icon},
6    view::{Attributes, Child, StaticClass, View, attributes, class, component, view},
7};
8
9/// Classes for the select control, with space for a custom dropdown arrow.
10const SELECT: StaticClass = class!(
11    "h-9 w-full appearance-none items-center rounded-lg border border-border",
12    "bg-transparent pr-8 pl-3 text-left text-sm transition-colors outline-none",
13    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
14    "aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive",
15    "focus-visible:ring-offset-background disabled:pointer-events-none",
16);
17
18/// Classes for browsers that support customizable select pickers. Other browsers use
19/// their native picker.
20const PICKER: StaticClass = class!(
21    "[&::picker(select)]:[appearance:base-select]",
22    "[&::picker(select)]:mt-1 [&::picker(select)]:rounded-lg",
23    "[&::picker(select)]:border [&::picker(select)]:border-border",
24    "[&::picker(select)]:bg-popover [&::picker(select)]:p-1",
25    "[&::picker(select)]:text-popover-foreground [&::picker(select)]:shadow-sm",
26    "[&::picker-icon]:hidden",
27    "[&_optgroup>legend]:px-2 [&_optgroup>legend]:py-1.5",
28    "[&_optgroup>legend]:text-xs [&_optgroup>legend]:font-medium",
29    "[&_optgroup>legend]:text-muted-foreground [&_optgroup>legend]:cursor-default",
30    "[&_optgroup>legend]:select-none",
31    "[&_option]:flex [&_option]:items-center [&_option]:gap-2 [&_option]:rounded-md",
32    "[&_option]:px-2 [&_option]:py-1.5 [&_option]:text-sm [&_option]:outline-none",
33    "[&_option:hover]:bg-foreground/5 [&_option:focus]:bg-foreground/5",
34    "[&_option:checked]:font-medium",
35    "[&_option::checkmark]:order-1 [&_option::checkmark]:ml-auto",
36    "[&_option::checkmark]:size-4 [&_option::checkmark]:shrink-0",
37    "[&_option::checkmark]:content-[''] [&_option::checkmark]:bg-muted-foreground",
38    "[&_option::checkmark]:[mask-size:100%_100%]",
39    "[&_option::checkmark]:[mask-image:var(--select-checkmark)]",
40);
41
42/// The icon marking the picker's checked option.
43const CHECKMARK: IconData = iconify_icon!("lucide:check");
44
45/// Supplies the checkmark icon as a data URI in `--select-checkmark` so CSS can use it
46/// as a mask.
47fn checkmark_style(cx: &Cx) -> String {
48    let svg = format!(
49        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}">{}</svg>"#,
50        CHECKMARK.view_box(),
51        CHECKMARK.into_body().render(cx),
52    );
53    let mut style = String::from(r#"--select-checkmark: url("data:image/svg+xml,"#);
54    // Percent-encode the characters that cannot appear in a double-quoted
55    // CSS url().
56    for char in svg.chars() {
57        match char {
58            '%' => style.push_str("%25"),
59            '"' => style.push_str("%22"),
60            '#' => style.push_str("%23"),
61            _ => style.push(char),
62        }
63    }
64    style.push_str(r#"")"#);
65    style
66}
67
68/// A styled native select control.
69///
70/// Pass `<option>` or `<optgroup>` elements as children. Classes in `attrs` apply to
71/// the wrapper, while other attributes and event handlers go on the `<select>`. The
72/// control fills its container by default. Set `aria-invalid="true"` to show the error
73/// border and focus ring.
74///
75/// Browsers with customizable select support also style the picker. For a styled group
76/// heading, place a `<legend>` first inside an `<optgroup>` and keep its `label`
77/// attribute for browsers that use the native picker.
78///
79/// ```ignore
80/// view! {
81///     select(
82///         attrs: attributes! { name="region" },
83///         <option>"eu-central-1"</option>
84///         <option>"us-east-1"</option>
85///     )
86/// }
87/// ```
88#[component]
89pub async fn select(
90    cx: &Cx,
91    #[default] mut attrs: Attributes,
92    #[default] child: Child<'_>,
93) -> Result<impl View> {
94    // `appearance: base-select` opts into the customizable picker. It is set
95    // from the wrapper because the descendant selector outranks the
96    // `appearance-none` fallback in specificity, making the outcome
97    // independent of stylesheet order; browsers without support drop the
98    // invalid declaration and keep the fallback.
99    Ok(view! {
100        <span
101            class=(class!(
102                "relative block has-[:disabled]:opacity-50",
103                "[&>select]:[appearance:base-select]",
104                "[&:has(select:open)>svg]:rotate-180",
105                attrs.remove("class"),
106            ))
107            style=(checkmark_style(cx))
108        >
109            <select class=(class!(SELECT, PICKER)) (attrs)>(child)</select>
110            icon(
111                data: iconify_icon!("lucide:chevron-down"),
112                attrs: attributes! {
113                    class="pointer-events-none absolute top-1/2 right-3 size-4 \
114                        -translate-y-1/2 text-muted-foreground transition-transform"
115                }
116            )
117        </span>
118    })
119}