Skip to main content

tablo_ui/components/primitives/
select.rs

1// SYNC: topcoat-ui-registry@0.10.0 sha256:796d685bbefa46c8bb2d4f1404fbfbdaa1a61c0dc8260cceda7eed66b495413d — 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}