mirror of
https://github.com/adoyle0/thaw.git
synced 2025-01-22 22:09:22 -05:00
feat: adds Combobox component
This commit is contained in:
parent
25b5485395
commit
c7be391441
9 changed files with 399 additions and 0 deletions
|
@ -59,6 +59,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
|
||||||
<Route path="/card" view=CardMdPage/>
|
<Route path="/card" view=CardMdPage/>
|
||||||
<Route path="/checkbox" view=CheckboxMdPage/>
|
<Route path="/checkbox" view=CheckboxMdPage/>
|
||||||
<Route path="/color-picker" view=ColorPickerMdPage/>
|
<Route path="/color-picker" view=ColorPickerMdPage/>
|
||||||
|
<Route path="/combobox" view=ComboboxMdPage/>
|
||||||
<Route path="/config-provider" view=ConfigProviderMdPage/>
|
<Route path="/config-provider" view=ConfigProviderMdPage/>
|
||||||
<Route path="/date-picker" view=DatePickerMdPage/>
|
<Route path="/date-picker" view=DatePickerMdPage/>
|
||||||
<Route path="/divider" view=DividerMdPage/>
|
<Route path="/divider" view=DividerMdPage/>
|
||||||
|
|
|
@ -187,6 +187,10 @@ pub(crate) fn gen_menu_data() -> Vec<MenuGroupOption> {
|
||||||
value: "/components/color-picker".into(),
|
value: "/components/color-picker".into(),
|
||||||
label: "Color Picker".into(),
|
label: "Color Picker".into(),
|
||||||
},
|
},
|
||||||
|
MenuItemOption {
|
||||||
|
value: "/components/combobox".into(),
|
||||||
|
label: "Combobox".into(),
|
||||||
|
},
|
||||||
MenuItemOption {
|
MenuItemOption {
|
||||||
value: "/components/config-provider".into(),
|
value: "/components/config-provider".into(),
|
||||||
label: "Config Provider".into(),
|
label: "Config Provider".into(),
|
||||||
|
|
33
demo_markdown/docs/combobox/mod.md
Normal file
33
demo_markdown/docs/combobox/mod.md
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Combobox
|
||||||
|
|
||||||
|
```rust demo
|
||||||
|
let value = RwSignal::new(vec![]);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Combobox value>
|
||||||
|
<ComboboxOption key="cat">
|
||||||
|
"Cat"
|
||||||
|
</ComboboxOption>
|
||||||
|
<ComboboxOption key="dog">
|
||||||
|
"Dog"
|
||||||
|
</ComboboxOption>
|
||||||
|
</Combobox>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiselect
|
||||||
|
|
||||||
|
```rust demo
|
||||||
|
let value = RwSignal::new(vec![]);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Combobox value multiselect=true>
|
||||||
|
<ComboboxOption key="cat">
|
||||||
|
"Cat"
|
||||||
|
</ComboboxOption>
|
||||||
|
<ComboboxOption key="dog">
|
||||||
|
"Dog"
|
||||||
|
</ComboboxOption>
|
||||||
|
</Combobox>
|
||||||
|
}
|
||||||
|
```
|
|
@ -41,6 +41,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
|
||||||
"CardMdPage" => "../docs/card/mod.md",
|
"CardMdPage" => "../docs/card/mod.md",
|
||||||
"CheckboxMdPage" => "../docs/checkbox/mod.md",
|
"CheckboxMdPage" => "../docs/checkbox/mod.md",
|
||||||
"ColorPickerMdPage" => "../docs/color_picker/mod.md",
|
"ColorPickerMdPage" => "../docs/color_picker/mod.md",
|
||||||
|
"ComboboxMdPage" => "../docs/combobox/mod.md",
|
||||||
"ConfigProviderMdPage" => "../docs/config_provider/mod.md",
|
"ConfigProviderMdPage" => "../docs/config_provider/mod.md",
|
||||||
"DatePickerMdPage" => "../docs/date_picker/mod.md",
|
"DatePickerMdPage" => "../docs/date_picker/mod.md",
|
||||||
"DividerMdPage" => "../docs/divider/mod.md",
|
"DividerMdPage" => "../docs/divider/mod.md",
|
||||||
|
|
192
thaw/src/combobox/combobox.css
Normal file
192
thaw/src/combobox/combobox.css
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
.thaw-combobox {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
column-gap: var(--spacingHorizontalXXS);
|
||||||
|
min-width: 250px;
|
||||||
|
height: 32px;
|
||||||
|
padding-right: var(--spacingHorizontalMNudge);
|
||||||
|
background-color: var(--colorNeutralBackground1);
|
||||||
|
border-radius: var(--borderRadiusMedium);
|
||||||
|
border: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
|
||||||
|
border-bottom-color: var(--colorNeutralStrokeAccessible);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox:hover {
|
||||||
|
border-color: var(--colorNeutralStroke1Hover);
|
||||||
|
border-bottom-color: var(--colorNeutralStrokeAccessible);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox:active {
|
||||||
|
border-color: var(--colorNeutralStroke1Pressed);
|
||||||
|
border-bottom-color: var(--colorNeutralStrokeAccessible);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox:focus-within {
|
||||||
|
outline-color: transparent;
|
||||||
|
outline-style: solid;
|
||||||
|
outline-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
left: -1px;
|
||||||
|
|
||||||
|
height: max(2px, var(--borderRadiusMedium));
|
||||||
|
border-bottom-left-radius: var(--borderRadiusMedium);
|
||||||
|
border-bottom-right-radius: var(--borderRadiusMedium);
|
||||||
|
border-bottom: var(--strokeWidthThick) solid var(--colorCompoundBrandStroke);
|
||||||
|
transition-delay: var(--curveAccelerateMid);
|
||||||
|
transition-duration: var(--durationUltraFast);
|
||||||
|
transition-property: transform;
|
||||||
|
transform: scaleX(0);
|
||||||
|
clip-path: inset(calc(100% - 2px) 0px 0px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox:focus-within::after {
|
||||||
|
transition-delay: var(--curveDecelerateMid);
|
||||||
|
transition-duration: var(--durationNormal);
|
||||||
|
transition-property: transform;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox:focus-within:active::after {
|
||||||
|
border-bottom-color: var(--colorCompoundBrandStrokePressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__input {
|
||||||
|
align-self: stretch;
|
||||||
|
background-color: var(--colorTransparentBackground);
|
||||||
|
line-height: var(--lineHeightBase300);
|
||||||
|
font-weight: var(--fontWeightRegular);
|
||||||
|
font-size: var(--fontSizeBase300);
|
||||||
|
font-family: var(--fontFamilyBase);
|
||||||
|
color: var(--colorNeutralForeground1);
|
||||||
|
padding: 0 0 0
|
||||||
|
calc(var(--spacingHorizontalMNudge) + var(--spacingHorizontalXXS));
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__input:focus {
|
||||||
|
outline-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__expand-icon {
|
||||||
|
display: block;
|
||||||
|
margin-left: var(--spacingHorizontalXXS);
|
||||||
|
color: var(--colorNeutralStrokeAccessible);
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__listbox {
|
||||||
|
row-gap: var(--spacingHorizontalXXS);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
max-height: 80vh;
|
||||||
|
background-color: var(--colorNeutralBackground1);
|
||||||
|
padding: var(--spacingHorizontalXS);
|
||||||
|
outline: 1px solid var(--colorTransparentStroke);
|
||||||
|
border-radius: var(--borderRadiusMedium);
|
||||||
|
box-shadow: var(--shadow16);
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-leave-active {
|
||||||
|
transform-origin: inherit;
|
||||||
|
transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1),
|
||||||
|
transform 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-enter-active {
|
||||||
|
transform-origin: inherit;
|
||||||
|
transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1),
|
||||||
|
transform 0.2s cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-enter-from,
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-leave-from,
|
||||||
|
.thaw-combobox__listbox.fade-in-scale-up-transition-enter-to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option {
|
||||||
|
column-gap: var(--spacingHorizontalXS);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacingVerticalSNudge) var(--spacingHorizontalS);
|
||||||
|
line-height: var(--lineHeightBase300);
|
||||||
|
font-size: var(--fontSizeBase300);
|
||||||
|
font-family: var(--fontFamilyBase);
|
||||||
|
color: var(--colorNeutralForeground1);
|
||||||
|
border-radius: var(--borderRadiusMedium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option:hover {
|
||||||
|
color: var(--colorNeutralForeground1Hover);
|
||||||
|
background-color: var(--colorNeutralBackground1Hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option:active {
|
||||||
|
color: var(--colorNeutralForeground1Pressed);
|
||||||
|
background-color: var(--colorNeutralBackground1Pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option__check-icon {
|
||||||
|
visibility: hidden;
|
||||||
|
|
||||||
|
margin-left: calc(var(--spacingHorizontalXXS) * -1);
|
||||||
|
margin-right: var(--spacingHorizontalXXS);
|
||||||
|
font-size: var(--fontSizeBase400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option--selected > .thaw-combobox-option__check-icon {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option__check-icon--multiselect > svg,
|
||||||
|
.thaw-combobox-option__check-icon > svg {
|
||||||
|
display: block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option__check-icon--multiselect {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
visibility: visible;
|
||||||
|
margin-left: calc(var(--spacingHorizontalXXS) * -1);
|
||||||
|
margin-right: var(--spacingHorizontalXXS);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--borderRadiusSmall);
|
||||||
|
border: var(--strokeWidthThin) solid var(--colorNeutralStrokeAccessible);
|
||||||
|
box-sizing: border-box;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thaw-combobox-option--selected
|
||||||
|
> .thaw-combobox-option__check-icon--multiselect {
|
||||||
|
border-color: var(--colorCompoundBrandBackground);
|
||||||
|
color: var(--colorNeutralForegroundInverted);
|
||||||
|
background-color: var(--colorCompoundBrandBackground);
|
||||||
|
}
|
112
thaw/src/combobox/combobox.rs
Normal file
112
thaw/src/combobox/combobox.rs
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
use crate::ConfigInjection;
|
||||||
|
use leptos::*;
|
||||||
|
use thaw_components::{Binder, CSSTransition, Follower, FollowerPlacement, FollowerWidth};
|
||||||
|
use thaw_utils::{mount_style, Model};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Combobox(
|
||||||
|
#[prop(optional, into)] value: Model<Vec<String>>,
|
||||||
|
#[prop(optional)] multiselect: bool,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
mount_style("combobox", include_str!("./combobox.css"));
|
||||||
|
let config_provider = ConfigInjection::use_();
|
||||||
|
let trigger_ref = NodeRef::<html::Div>::new();
|
||||||
|
let listbox_ref = NodeRef::<html::Div>::new();
|
||||||
|
let is_show_listbox = RwSignal::new(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Binder target_ref=trigger_ref>
|
||||||
|
<div
|
||||||
|
class="thaw-combobox"
|
||||||
|
ref=trigger_ref
|
||||||
|
on:click=move |_| {
|
||||||
|
is_show_listbox.set(true);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-expanded="true"
|
||||||
|
role="combobox"
|
||||||
|
class="thaw-combobox__input"
|
||||||
|
prop:value=move || {
|
||||||
|
value.with(|value| {
|
||||||
|
if multiselect {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
value.first().cloned()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-expanded="true"
|
||||||
|
role="button"
|
||||||
|
aria-label="Open"
|
||||||
|
class="thaw-combobox__expand-icon"
|
||||||
|
>
|
||||||
|
<svg fill="currentColor" aria-hidden="true" width="1em" height="1em" viewBox="0 0 20 20">
|
||||||
|
<path d="M15.85 7.65c.2.2.2.5 0 .7l-5.46 5.49a.55.55 0 0 1-.78 0L4.15 8.35a.5.5 0 1 1 .7-.7L10 12.8l5.15-5.16c.2-.2.5-.2.7 0Z" fill="currentColor">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Follower
|
||||||
|
slot
|
||||||
|
show=is_show_listbox
|
||||||
|
placement=FollowerPlacement::BottomStart
|
||||||
|
width=FollowerWidth::MinTarget
|
||||||
|
>
|
||||||
|
<Provider value=ComboboxInjection{value, multiselect}>
|
||||||
|
<CSSTransition
|
||||||
|
node_ref=listbox_ref
|
||||||
|
name="fade-in-scale-up-transition"
|
||||||
|
appear=is_show_listbox.get_untracked()
|
||||||
|
show=is_show_listbox
|
||||||
|
let:display
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="thaw-config-provider thaw-combobox__listbox"
|
||||||
|
style=move || display.get()
|
||||||
|
data-thaw-id=config_provider.id().clone()
|
||||||
|
ref=listbox_ref
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</CSSTransition>
|
||||||
|
</Provider>
|
||||||
|
</Follower>
|
||||||
|
</Binder>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct ComboboxInjection {
|
||||||
|
value: Model<Vec<String>>,
|
||||||
|
pub multiselect: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComboboxInjection {
|
||||||
|
pub fn use_() -> Self {
|
||||||
|
expect_context()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selected(&self, key: &String) -> bool {
|
||||||
|
self.value.with(|value| value.contains(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_option_select(&self, key: &String) {
|
||||||
|
self.value.update(|value| {
|
||||||
|
if self.multiselect {
|
||||||
|
if let Some(index) = value.iter().position(|k| k == key) {
|
||||||
|
value.remove(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value.clear();
|
||||||
|
}
|
||||||
|
value.push(key.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
49
thaw/src/combobox/combobox_option.rs
Normal file
49
thaw/src/combobox/combobox_option.rs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
use crate::ComboboxInjection;
|
||||||
|
use leptos::*;
|
||||||
|
use thaw_components::{If, Then};
|
||||||
|
use thaw_utils::class_list;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ComboboxOption(#[prop(into)] key: String, children: Children) -> impl IntoView {
|
||||||
|
let combobox = ComboboxInjection::use_();
|
||||||
|
let key = StoredValue::new(key);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected="true"
|
||||||
|
class=class_list![
|
||||||
|
"thaw-combobox-option",
|
||||||
|
("thaw-combobox-option--selected", move || key.with_value(|key| combobox.is_selected(&key)))
|
||||||
|
]
|
||||||
|
on:click=move |_| {
|
||||||
|
key.with_value(|key| combobox.on_option_select(key));
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if combobox.multiselect {
|
||||||
|
view! {
|
||||||
|
<span aria-hidden="true" class="thaw-combobox-option__check-icon--multiselect">
|
||||||
|
<If cond=Signal::derive(move || key.with_value(|key| combobox.is_selected(&key)))>
|
||||||
|
<Then slot>
|
||||||
|
<svg fill="currentColor" aria-hidden="true" width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<path d="M9.76 3.2c.3.29.32.76.04 1.06l-4.25 4.5a.75.75 0 0 1-1.08.02L2.22 6.53a.75.75 0 0 1 1.06-1.06l1.7 1.7L8.7 3.24a.75.75 0 0 1 1.06-.04Z" fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</Then>
|
||||||
|
</If>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<span aria-hidden="true" class="thaw-combobox-option__check-icon">
|
||||||
|
<svg fill="currentColor" aria-hidden="true" width="1em" height="1em" viewBox="0 0 20 20">
|
||||||
|
<path d="M7.03 13.9 3.56 10a.75.75 0 0 0-1.12 1l4 4.5c.29.32.79.34 1.09.03l10.5-10.5a.75.75 0 0 0-1.06-1.06l-9.94 9.94Z" fill="currentColor"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
5
thaw/src/combobox/mod.rs
Normal file
5
thaw/src/combobox/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
mod combobox;
|
||||||
|
mod combobox_option;
|
||||||
|
|
||||||
|
pub use combobox::*;
|
||||||
|
pub use combobox_option::*;
|
|
@ -12,6 +12,7 @@ mod card;
|
||||||
mod checkbox;
|
mod checkbox;
|
||||||
mod code;
|
mod code;
|
||||||
mod color_picker;
|
mod color_picker;
|
||||||
|
mod combobox;
|
||||||
mod config_provider;
|
mod config_provider;
|
||||||
mod date_picker;
|
mod date_picker;
|
||||||
mod divider;
|
mod divider;
|
||||||
|
@ -59,6 +60,7 @@ pub use card::*;
|
||||||
pub use checkbox::*;
|
pub use checkbox::*;
|
||||||
pub use code::*;
|
pub use code::*;
|
||||||
pub use color_picker::*;
|
pub use color_picker::*;
|
||||||
|
pub use combobox::*;
|
||||||
pub use config_provider::*;
|
pub use config_provider::*;
|
||||||
pub use date_picker::*;
|
pub use date_picker::*;
|
||||||
pub use divider::*;
|
pub use divider::*;
|
||||||
|
|
Loading…
Add table
Reference in a new issue