Feat/tag picker component (#251)

* feat: adds TagPicker component

* feat: improved TagPicker component

* feat: improved TagPicker component

* feat: adds TagGroup and InteractionTag

* feat: Tag adds size prop

* feat: TagGroup adds size prop

* feat: change Tag's closable to dismissible

* feat: improved TagPicker component

* feat: improved TagPicker component

* feat: improved TagPicker component
This commit is contained in:
luoxiaozero 2024-09-08 22:01:41 +08:00 committed by GitHub
parent 1ab9dd4c9f
commit 583b03100e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1121 additions and 64 deletions

View file

@ -107,9 +107,11 @@ fn TheRouter() -> impl IntoView {
<Route path=path!("/switch") view=SwitchMdPage/>
<Route path=path!("/tab-list") view=TabListMdPage/>
<Route path=path!("/table") view=TableMdPage/>
<Route path=path!("/tag") view=TagMdPage/>
}.into_inner()}
{view!{
<Route path=path!("/tag") view=TagMdPage/>
<Route path=path!("/tag-group") view=TagGroupMdPage/>
<Route path=path!("/tag-picker") view=TagPickerMdPage/>
<Route path=path!("/text") view=TextMdPage/>
<Route path=path!("/textarea") view=TextareaMdPage/>
<Route path=path!("/time-picker") view=TimePickerMdPage/>

View file

@ -196,7 +196,7 @@ pub fn SiteHeader() -> impl IntoView {
</Caption1Strong>
{
children.into_iter().map(|item| {
let NavItemOption { label, value } = item;
let NavItemOption { label, value, .. } = item;
view! {
<MenuItem value=value>{label}</MenuItem>
}

View file

@ -1,3 +1,5 @@
use std::vec;
use crate::components::SiteHeader;
use leptos::prelude::*;
use leptos_meta::Style;
@ -5,6 +7,7 @@ use leptos_router::{
components::Outlet,
hooks::{use_location, use_navigate},
};
use tachys::view::any_view::AnyView;
use thaw::*;
#[component]
@ -66,14 +69,7 @@ pub fn ComponentsPage() -> impl IntoView {
<Caption1Strong style="margin-inline-start: 10px; margin-top: 10px; display: inline-block">
{label}
</Caption1Strong>
{
children.into_iter().map(|item| {
let NavItemOption { label, value } = item;
view! {
<NavItem value>{label}</NavItem>
}
}).collect_view()
}
{VecIntoView::into_view(children)}
}
}).collect_view()
}
@ -93,20 +89,77 @@ pub(crate) struct NavGroupOption {
}
pub(crate) struct NavItemOption {
pub group: Option<&'static str>,
pub label: &'static str,
pub value: &'static str,
}
trait VecIntoView {
fn into_view(self) -> Vec<AnyView<Dom>>;
}
impl VecIntoView for Vec<NavItemOption> {
fn into_view(self) -> Vec<AnyView<Dom>> {
let mut iter = self.into_iter().peekable();
let mut views = vec![];
while let Some(item) = iter.next() {
let NavItemOption {
group,
label,
value,
} = item;
if let Some(group) = group {
let mut sub_views = vec![];
while let Some(item) = iter.peek() {
if item.group != Some(group) {
break;
}
let NavItemOption { label, value, .. } = iter.next().unwrap();
sub_views.push(view! {
<NavSubItem value=value>
{label}
</NavSubItem>
});
}
views.push(
view! {
<NavCategory value=group>
<NavCategoryItem slot>
{group}
</NavCategoryItem>
<NavSubItem value=value>
{label}
</NavSubItem>
{sub_views}
</NavCategory>
}
.into_any(),
);
} else {
views.push(
view! {
<NavItem value>{label}</NavItem>
}
.into_any(),
);
}
}
views
}
}
pub(crate) fn gen_nav_data() -> Vec<NavGroupOption> {
vec![
NavGroupOption {
label: "Getting Started",
children: vec![
NavItemOption {
group: None,
value: "/guide/installation",
label: "Installation",
},
NavItemOption {
group: None,
value: "/guide/server-sider-rendering",
label: "Server Sider Rendering",
},
@ -125,202 +178,262 @@ pub(crate) fn gen_nav_data() -> Vec<NavGroupOption> {
label: "Components",
children: vec![
NavItemOption {
group: None,
value: "/components/accordion",
label: "Accordion",
},
NavItemOption {
group: None,
value: "/components/anchor",
label: "Anchor",
},
NavItemOption {
group: None,
value: "/components/auto-complete",
label: "Auto Complete",
},
NavItemOption {
group: None,
value: "/components/avatar",
label: "Avatar",
},
NavItemOption {
group: None,
value: "/components/back-top",
label: "Back Top",
},
NavItemOption {
group: None,
value: "/components/badge",
label: "Badge",
},
NavItemOption {
group: None,
value: "/components/breadcrumb",
label: "Breadcrumb",
},
NavItemOption {
group: None,
value: "/components/button",
label: "Button",
},
NavItemOption {
group: None,
value: "/components/calendar",
label: "Calendar",
},
NavItemOption {
group: None,
value: "/components/card",
label: "Card",
},
NavItemOption {
group: None,
value: "/components/checkbox",
label: "Checkbox",
},
NavItemOption {
group: None,
value: "/components/color-picker",
label: "Color Picker",
},
NavItemOption {
group: None,
value: "/components/combobox",
label: "Combobox",
},
NavItemOption {
group: None,
value: "/components/config-provider",
label: "Config Provider",
},
NavItemOption {
group: None,
value: "/components/date-picker",
label: "Date Picker",
},
NavItemOption {
group: None,
value: "/components/dialog",
label: "Dialog",
},
NavItemOption {
group: None,
value: "/components/divider",
label: "Divider",
},
NavItemOption {
group: None,
value: "/components/drawer",
label: "Drawer",
},
NavItemOption {
group: None,
value: "/components/field",
label: "Field",
},
NavItemOption {
group: None,
value: "/components/grid",
label: "Grid",
},
NavItemOption {
group: None,
value: "/components/icon",
label: "Icon",
},
NavItemOption {
group: None,
value: "/components/image",
label: "Image",
},
NavItemOption {
group: None,
value: "/components/input",
label: "Input",
},
NavItemOption {
group: None,
value: "/components/layout",
label: "Layout",
},
NavItemOption {
group: None,
value: "/components/link",
label: "Link",
},
NavItemOption {
group: None,
value: "/components/loading-bar",
label: "Loading Bar",
},
NavItemOption {
group: None,
value: "/components/menu",
label: "Menu",
},
NavItemOption {
group: None,
value: "/components/message-bar",
label: "Message Bar",
},
NavItemOption {
group: None,
value: "/components/nav",
label: "Nav",
},
NavItemOption {
group: None,
value: "/components/pagination",
label: "Pagination",
},
NavItemOption {
group: None,
value: "/components/popover",
label: "Popover",
},
NavItemOption {
group: None,
value: "/components/progress-bar",
label: "ProgressBar",
},
NavItemOption {
group: None,
value: "/components/radio",
label: "Radio",
},
NavItemOption {
group: None,
value: "/components/scrollbar",
label: "Scrollbar",
},
NavItemOption {
group: None,
value: "/components/select",
label: "Select",
},
NavItemOption {
group: None,
value: "/components/skeleton",
label: "Skeleton",
},
NavItemOption {
group: None,
value: "/components/slider",
label: "Slider",
},
NavItemOption {
group: None,
value: "/components/space",
label: "Space",
},
NavItemOption {
group: None,
value: "/components/spin-button",
label: "Spin Button",
},
NavItemOption {
group: None,
value: "/components/spinner",
label: "Spinner",
},
NavItemOption {
group: None,
value: "/components/switch",
label: "Switch",
},
NavItemOption {
group: None,
value: "/components/tab-list",
label: "Tab List",
},
NavItemOption {
group: None,
value: "/components/table",
label: "Table",
},
NavItemOption {
group: Some("Tag"),
value: "/components/tag",
label: "Tag",
},
NavItemOption {
group: Some("Tag"),
value: "/components/tag-group",
label: "Tag Group",
},
NavItemOption {
group: None,
value: "/components/tag-picker",
label: "Tag Picker",
},
NavItemOption {
group: None,
value: "/components/text",
label: "Text",
},
NavItemOption {
group: None,
value: "/components/textarea",
label: "Textarea",
},
NavItemOption {
group: None,
value: "/components/time-picker",
label: "Time Picker",
},
NavItemOption {
group: None,
value: "/components/toast",
label: "Toast",
},
NavItemOption {
group: None,
value: "/components/tooltip",
label: "Tooltip",
},
NavItemOption {
group: None,
value: "/components/upload",
label: "Upload",
},

View file

@ -67,6 +67,8 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
"TabListMdPage" => "../../thaw/src/tab_list/docs/mod.md",
"TableMdPage" => "../../thaw/src/table/docs/mod.md",
"TagMdPage" => "../../thaw/src/tag/docs/mod.md",
"TagGroupMdPage" => "../../thaw/src/tag/docs/tag_group/mod.md",
"TagPickerMdPage" => "../../thaw/src/tag_picker/docs/mod.md",
"TextMdPage" => "../../thaw/src/text/docs/mod.md",
"TextareaMdPage" => "../../thaw/src/textarea/docs/mod.md",
"TimePickerMdPage" => "../../thaw/src/time_picker/docs/mod.md",

View file

@ -3,7 +3,7 @@ use crate::{ConfigInjection, _aria::ActiveDescendantController};
use leptos::{context::Provider, ev, html, prelude::*};
use std::sync::Arc;
use thaw_components::CSSTransition;
use thaw_utils::mount_style;
use thaw_utils::{mount_style, BoxCallback};
use web_sys::{HtmlElement, Node};
#[component]
@ -12,6 +12,7 @@ pub fn Listbox(
class: &'static str,
set_listbox: Arc<dyn Fn(Node) + Send + Sync>,
listbox_ref: NodeRef<html::Div>,
#[prop(optional)] on_hidden: StoredValue<Vec<BoxCallback>>,
children: Children,
) -> impl IntoView {
mount_style("listbox", include_str!("./listbox.css"));
@ -27,6 +28,13 @@ pub fn Listbox(
}
}
});
let on_after_leave = move || {
if let Some(list) =
on_hidden.try_update_value(|list| list.drain(..).collect::<Vec<BoxCallback>>())
{
list.into_iter().for_each(|f| f());
}
};
on_cleanup(move || {
drop(effect);
});
@ -39,6 +47,7 @@ pub fn Listbox(
appear=open.get_untracked()
show=open
let:display
on_after_leave
>
<div
class=format!("thaw-config-provider thaw-listbox {class}")

View file

@ -44,6 +44,7 @@ mod switch;
mod tab_list;
mod table;
mod tag;
mod tag_picker;
mod text;
mod textarea;
mod theme;
@ -97,6 +98,7 @@ pub use switch::*;
pub use tab_list::*;
pub use table::*;
pub use tag::*;
pub use tag_picker::*;
pub use text::*;
pub use textarea::*;
pub use thaw_utils::{ComponentRef, SignalWatch};

View file

@ -2,32 +2,79 @@
```rust demo
view! {
<Tag>"default"</Tag>
<Space>
<Tag>"default"</Tag>
<InteractionTag>
<InteractionTagPrimary>
"Interaction Tag"
</InteractionTagPrimary>
</InteractionTag>
</Space>
}
```
### Closable
### Size
```rust demo
// let message = use_message();
let success = move |_: ev::MouseEvent| {
// message.create(
// "tag close".into(),
// MessageVariant::Success,
// Default::default(),
// );
view! {
<Space vertical=true>
<Space>
<Tag >"Medium"</Tag>
<Tag size=TagSize::Small>"Small"</Tag>
<Tag size=TagSize::ExtraSmall>"Extra small"</Tag>
</Space>
<Space>
<Tag dismissible=true>"Medium"</Tag>
<Tag dismissible=true size=TagSize::Small>"Small"</Tag>
<Tag dismissible=true size=TagSize::ExtraSmall>"Extra small"</Tag>
</Space>
</Space>
}
```
### Dismiss
```rust demo
let toaster = ToasterInjection::expect_context();
let on_dismiss = move |_| {
toaster.dispatch_toast(view! {
<Toast>
<ToastTitle>"Tag"</ToastTitle>
<ToastBody>
"Tag dismiss"
</ToastBody>
</Toast>
}.into_any(), Default::default());
};
view! {
<Tag closable=true on_close=success>"Default"</Tag>
<Tag dismissible=true on_dismiss=on_dismiss>"Default"</Tag>
}
```
### Tag Props
| Name | Type | Default | Description |
| -------- | ---------------------------------------- | -------------------- | ------------------------------------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| closable | `MaybeSignal<bool>` | `false` | Whether the tag shows a close button. |
| on_close | `Option<ArcOneCallback<ev::MouseEvent>>` | `None` | Close clicked callback. |
| children | `Children` | | |
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| class | `MaybeProp<String>` | `Default::default()` | |
| size | `Option<MaybeSignal<TagSize>>` | `None` | Size of the tag. |
| dismissible | `MaybeSignal<bool>` | `false` | A Tag can be dismissible. |
| on_dismiss | `Option<ArcOneCallback<ev::MouseEvent>>` | `None` | Callback for when a tag is dismissed. |
| value | `Option<String>` | `None` | Unique value identifying the tag within a TagGroup. |
| children | `Children` | | |
### InteractionTag Props
| Name | Type | Default | Description |
| -------- | ------------------------------ | -------------------- | ---------------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| size | `Option<MaybeSignal<TagSize>>` | `None` | Size of the tag. |
| children | `Children` | | |
### InteractionTagPrimary Props
| Name | Type | Default | Description |
| -------- | ------------------- | -------------------- | ----------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| children | `Children` | | |

View file

@ -0,0 +1,57 @@
# Tag Group
```rust demo
view! {
<Space vertical=true>
<TagGroup attr:role="list">
<Tag attr:role="listitem">"Tag 1"</Tag>
<Tag attr:role="listitem">"Tag 2"</Tag>
<Tag attr:role="listitem">"Tag 3"</Tag>
</TagGroup>
<TagGroup>
<InteractionTag>
<InteractionTagPrimary>"Tag 1"</InteractionTagPrimary>
</InteractionTag>
<InteractionTag>
<InteractionTagPrimary>"Tag 2"</InteractionTagPrimary>
</InteractionTag>
<InteractionTag>
<InteractionTagPrimary>"Tag 3"</InteractionTagPrimary>
</InteractionTag>
</TagGroup>
</Space>
}
```
### Sizes
```rust demo
view! {
<Space vertical=true>
<TagGroup size=TagSize::Small>
<Tag >"Tag 1"</Tag>
<Tag dismissible=true>"Tag 1"</Tag>
<InteractionTag>
<InteractionTagPrimary>"Tag 1"</InteractionTagPrimary>
</InteractionTag>
</TagGroup>
<TagGroup size=TagSize::ExtraSmall>
<Tag >"Tag 1"</Tag>
<Tag dismissible=true>"Tag 1"</Tag>
<InteractionTag>
<InteractionTagPrimary>"Tag 1"</InteractionTagPrimary>
</InteractionTag>
</TagGroup>
</Space>
}
```
### TagGroup Props
| Name | Type | Default | Description |
| ----------- | -------------------------------- | -------------------- | ------------------------------------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| size | `MaybeSignal<TagSize>` | `TagSize::Medium` | Size of the tag. |
| dismissible | `MaybeSignal<bool>` | `false` | A Tag can be dismissible. |
| on_dismiss | `Option<ArcOneCallback<String>>` | `None` | Callback for when a tag is dismissed. |
| children | `Children` | | |

View file

@ -0,0 +1,72 @@
.thaw-interaction-tag {
display: inline-flex;
align-items: center;
box-sizing: border-box;
width: fit-content;
height: 32px;
border-radius: var(--borderRadiusMedium);
}
.thaw-interaction-tag--small {
height: 24px;
}
.thaw-interaction-tag--extra-small {
height: 20px;
}
.thaw-interaction-tag-primary {
display: inline-grid;
height: 100%;
align-items: center;
grid-template-areas:
"media primary"
"media secondary";
padding: 0 7px;
background-color: var(--colorNeutralBackground3);
color: var(--colorNeutralForeground2);
font-family: inherit;
appearance: button;
text-align: unset;
border: var(--strokeWidthThin) solid var(--colorTransparentStroke);
border-radius: var(--borderRadiusMedium);
}
.thaw-interaction-tag--small .thaw-interaction-tag-primary {
padding: 0 5px;
}
.thaw-interaction-tag--extra-small .thaw-interaction-tag-primary {
padding: 0 5px;
}
.thaw-interaction-tag-primary:hover {
color: var(--colorNeutralForeground2Hover);
background-color: var(--colorNeutralBackground3Hover);
cursor: pointer;
}
.thaw-interaction-tag-primary:active {
color: var(--colorNeutralForeground2Pressed);
background-color: var(--colorNeutralBackground3Pressed);
}
.thaw-interaction-tag-primary__primary-text {
grid-row-end: secondary;
grid-row-start: primary;
grid-column-start: primary;
white-space: nowrap;
padding-left: var(--spacingHorizontalXXS);
padding-right: var(--spacingHorizontalXXS);
padding-bottom: var(--spacingHorizontalXXS);
line-height: var(--lineHeightBase300);
font-weight: var(--fontWeightRegular);
font-size: var(--fontSizeBase300);
font-family: var(--fontFamilyBase);
}
.thaw-interaction-tag--extra-small .thaw-interaction-tag-primary__primary-text,
.thaw-interaction-tag--extra-small .thaw-interaction-tag-primary__primary-text {
font-size: var(--fontSizeBase200);
line-height: var(--lineHeightBase200);
}

View file

@ -0,0 +1,44 @@
use leptos::prelude::*;
use thaw_utils::{class_list, mount_style};
use super::{TagSize, TagGroupInjection};
#[component]
pub fn InteractionTag(
#[prop(optional, into)] class: MaybeProp<String>,
/// Size of the tag.
#[prop(optional, into)]
size: Option<MaybeSignal<TagSize>>,
children: Children,
) -> impl IntoView {
mount_style("interaction-tag", include_str!("./interaction-tag.css"));
let tag_group = TagGroupInjection::use_context();
let size_class = {
if let Some(size) = size {
Some(size)
} else if let Some(tag_group) = tag_group {
Some(tag_group.size)
} else {
None
}
};
view! {
<div class=class_list![
"thaw-interaction-tag",
size_class.map(|size| move || format!("thaw-interaction-tag--{}", size.get().as_str())),
class
]>{children()}</div>
}
}
#[component]
pub fn InteractionTagPrimary(
#[prop(optional, into)] class: MaybeProp<String>,
children: Children,
) -> impl IntoView {
view! {
<button class=class_list!["thaw-interaction-tag-primary", class]>
<span class="thaw-interaction-tag-primary__primary-text">{children()}</span>
</button>
}
}

View file

@ -1,36 +1,84 @@
mod interaction_tag;
mod tag_group;
pub use interaction_tag::*;
pub use tag_group::*;
use leptos::{either::Either, ev, prelude::*};
use thaw_utils::{class_list, mount_style, ArcOneCallback};
#[component]
pub fn Tag(
#[prop(optional, into)] class: MaybeProp<String>,
/// Whether the tag shows a close button.
/// Size of the tag.
#[prop(optional, into)]
closable: MaybeSignal<bool>,
/// Close clicked callback.
size: Option<MaybeSignal<TagSize>>,
/// A Tag can be dismissible.
#[prop(optional, into)]
on_close: Option<ArcOneCallback<ev::MouseEvent>>,
dismissible: MaybeSignal<bool>,
/// Callback for when a tag is dismissed.
#[prop(optional, into)]
on_dismiss: Option<ArcOneCallback<ev::MouseEvent>>,
/// Unique value identifying the tag within a TagGroup.
#[prop(optional, into)]
value: Option<String>,
children: Children,
) -> impl IntoView {
mount_style("tag", include_str!("./tag.css"));
let (group_size, group_on_dismiss, group_dismissible) = TagGroupInjection::use_context()
.map(
|TagGroupInjection {
size,
on_dismiss,
dismissible,
}| {
if value.is_none() {
(Some(size), None, None)
} else {
(Some(size), on_dismiss, Some(dismissible))
}
},
)
.unwrap_or_default();
let size_class = {
if let Some(size) = size {
Some(size)
} else if let Some(group_size) = group_size {
Some(group_size)
} else {
None
}
};
view! {
<span class=class_list!["thaw-tag", ("thaw-tag--closable", move || closable.get()), class]>
<span class=class_list![
"thaw-tag",
("thaw-tag--dismissible", move || group_dismissible.map_or_else(|| dismissible.get(), |d| d.get())),
size_class.map(|size| move || format!("thaw-tag--{}", size.get().as_str())),
class
]>
<span class="thaw-tag__primary-text">{children()}</span>
{move || {
let on_close = on_close.clone();
let on_close = move |event| {
let Some(on_close) = on_close.as_ref() else {
return;
if group_dismissible.map_or_else(|| dismissible.get(), |d| d.get()) {
let on_dismiss = on_dismiss.clone();
let group_on_dismiss = group_on_dismiss.clone();
let value = value.clone();
let on_dismiss = move |event: ev::MouseEvent| {
if let Some(on_dismiss) = group_on_dismiss.as_ref() {
event.prevent_default();
on_dismiss(value.clone().unwrap());
}
let Some(on_dismiss) = on_dismiss.as_ref() else {
return;
};
on_dismiss(event);
};
on_close(event);
};
if closable.get() {
Either::Left(
view! {
<button class="thaw-tag__close" on:click=on_close>
<button class="thaw-tag__dismiss" on:click=on_dismiss>
<svg
fill="currentColor"
aria-hidden="true"
@ -54,3 +102,21 @@ pub fn Tag(
</span>
}
}
#[derive(Debug, Default, Clone, Copy)]
pub enum TagSize {
#[default]
Medium,
Small,
ExtraSmall,
}
impl TagSize {
pub fn as_str(&self) -> &'static str {
match self {
Self::Medium => "medium",
Self::Small => "small",
Self::ExtraSmall => "extra-small",
}
}
}

View file

@ -0,0 +1,4 @@
.thaw-tag-group {
display: inline-flex;
column-gap: var(--spacingHorizontalS);
}

View file

@ -17,7 +17,17 @@
border-radius: var(--borderRadiusMedium);
}
.thaw-tag--closable {
.thaw-tag--small {
height: 24px;
padding: 0 5px;
}
.thaw-tag--extra-small {
height: 20px;
padding: 0 5px;
}
.thaw-tag--dismissible {
padding-right: 0;
}
@ -34,7 +44,13 @@
color: var(--colorNeutralForeground2);
}
.thaw-tag__close {
.thaw-tag--small .thaw-tag__primary-text,
.thaw-tag--extra-small .thaw-tag__primary-text {
font-size: var(--fontSizeBase200);
line-height: var(--lineHeightBase200);
}
.thaw-tag__dismiss {
grid-area: dismissIcon;
display: flex;
padding: 0;
@ -46,15 +62,27 @@
cursor: pointer;
}
.thaw-tag__close > svg {
.thaw-tag--small .thaw-tag__dismiss {
padding-left: var(--spacingHorizontalXXS);
font-size: 16px;
padding-right: 5px;
}
.thaw-tag--extra-small .thaw-tag__dismiss {
padding-left: var(--spacingHorizontalXXS);
font-size: 12px;
padding-right: 5px;
}
.thaw-tag__dismiss > svg {
display: inline;
line-height: 0;
}
.thaw-tag__close:hover {
.thaw-tag__dismiss:hover {
color: var(--colorCompoundBrandForeground1Hover);
}
.thaw-tag__close:active {
.thaw-tag__dismiss:active {
color: var(--colorCompoundBrandForeground1Pressed);
}

43
thaw/src/tag/tag_group.rs Normal file
View file

@ -0,0 +1,43 @@
use super::TagSize;
use leptos::{context::Provider, prelude::*};
use thaw_utils::{class_list, mount_style, ArcOneCallback};
#[component]
pub fn TagGroup(
#[prop(optional, into)] class: MaybeProp<String>,
/// Size of the tag.
#[prop(optional, into)]
size: MaybeSignal<TagSize>,
/// Callback for when a tag is dismissed.
#[prop(optional, into)]
on_dismiss: Option<ArcOneCallback<String>>,
/// A Tag can be dismissible.
#[prop(optional, into)]
dismissible: MaybeSignal<bool>,
children: Children,
) -> impl IntoView {
mount_style("tag-group", include_str!("./tag-group.css"));
view! {
<div class=class_list!["thaw-tag-group", class]>
<Provider value=TagGroupInjection {
size,
on_dismiss,
dismissible,
}>{children()}</Provider>
</div>
}
}
#[derive(Clone)]
pub(crate) struct TagGroupInjection {
pub size: MaybeSignal<TagSize>,
pub on_dismiss: Option<ArcOneCallback<String>>,
pub dismissible: MaybeSignal<bool>,
}
impl TagGroupInjection {
pub fn use_context() -> Option<Self> {
use_context()
}
}

View file

@ -0,0 +1,70 @@
# Tag Picker
```rust demo
let selected_options = RwSignal::new(vec![]);
let options = vec!["Cat", "Dog"];
view! {
<TagPicker selected_options>
<TagPickerControl slot>
<TagPickerGroup>
{move || {
selected_options.get().into_iter().map(|option| view!{
<Tag value=option.clone()>
{option}
</Tag>
}).collect_view()
}}
</TagPickerGroup>
<TagPickerInput />
</TagPickerControl>
{
move || {
selected_options.with(|selected_options| {
options.iter().filter_map(|option| {
if selected_options.iter().any(|o| o == option) {
return None
} else {
Some(view! {
<TagPickerOption value=option.clone() text=option.clone() />
})
}
}).collect_view()
})
}
}
</TagPicker>
}
```
### TagPicker Props
| Name | Type | Default | Description |
| ------------------ | ----------------------- | -------------------- | --------------------------------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| selected_option | `Model<Vec<String>>` | `Default::default()` | An array of selected option keys. |
| tag_picker_control | slot `TagPickerControl` | | |
| children | `Children` | | |
### TagPickerGroup Props
| Name | Type | Default | Description |
| -------- | ------------------- | -------------------- | ----------- |
| class | `MaybeProp<String>` | `Default::default()` | |
| children | `Children` | | |
### TagPickerInput Props
| Name | Type | Default | Description |
| ----- | ------------------- | -------------------- | ----------- |
| class | `MaybeProp<String>` | `Default::default()` | |
### TagPickerOption Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| class | `MaybeProp<String>` | `Default::default()` | |
| disable | `MaybeProp<bool>` | `false` | Sets an option to the disabled state. |
| value | `String` | | Defines a unique identifier for the option. |
| text | `String` | | An optional override the string value of the Option's display text, defaulting to the Option's child content. |
| children | `Option<Children>` | `None` | |

View file

@ -0,0 +1,9 @@
mod tag_picker;
mod tag_picker_input;
mod tag_picker_option;
mod tag_picker_group;
pub use tag_picker::*;
pub use tag_picker_input::*;
pub use tag_picker_option::*;
pub use tag_picker_group::*;

View file

@ -0,0 +1,156 @@
.thaw-tag-picker-control {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacingHorizontalXXS);
min-width: 250px;
min-height: 32px;
padding-right: calc(var(--spacingHorizontalM) + 18px);
padding-left: var(--spacingHorizontalM);
background-color: var(--colorNeutralBackground1);
border-radius: var(--borderRadiusMedium);
border: var(--strokeWidthThin) solid var(--colorNeutralStroke1);
border-bottom-color: var(--colorNeutralStrokeAccessible);
box-sizing: border-box;
}
.thaw-tag-picker-control::after {
content: "";
position: absolute;
bottom: -1px;
right: -1px;
left: -1px;
height: max(2px, var(--borderRadiusMedium));
clip-path: inset(calc(100% - 2px) 0px 0px);
border-bottom: var(--strokeWidthThick) solid var(--colorCompoundBrandStroke);
border-bottom-right-radius: var(--borderRadiusMedium);
border-bottom-left-radius: var(--borderRadiusMedium);
transition-delay: var(--curveAccelerateMid);
transition-duration: var(--durationUltraFast);
transition-property: transform;
transform: scaleX(0);
box-sizing: border-box;
}
.thaw-tag-picker-control:focus-within::after {
transition-delay: var(--curveDecelerateMid);
transition-duration: var(--durationNormal);
transition-property: transform;
transform: scaleX(1);
}
.thaw-tag-picker-control__aside {
position: absolute;
right: var(--spacingHorizontalM);
top: 0px;
display: grid;
align-items: center;
grid-template-rows: minmax(32px, auto) 1fr;
grid-template-columns: repeat(2, auto);
min-height: 32px;
height: 100%;
cursor: text;
}
.thaw-tag-picker-control__expand-icon {
display: block;
margin-left: var(--spacingHorizontalXXS);
color: var(--colorNeutralStrokeAccessible);
font-size: 16px;
cursor: pointer;
box-sizing: border-box;
}
.thaw-tag-picker-input {
flex-grow: 1;
width: 0;
min-width: 24px;
max-width: 100%;
padding: var(--spacingVerticalSNudge) 0 var(--spacingVerticalSNudge) 0;
background-color: var(--colorTransparentBackground);
color: var(--colorNeutralForeground1);
line-height: var(--lineHeightBase300);
font-weight: var(--fontWeightRegular);
font-size: var(--fontSizeBase300);
font-family: var(--fontFamilyBase);
box-sizing: border-box;
border: none;
}
.thaw-tag-picker-input:focus {
outline-style: none;
}
.thaw-tag-picker__listbox {
display: flex;
flex-direction: column;
row-gap: var(--spacingHorizontalXXS);
overflow-y: auto;
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;
}
.thaw-tag-picker-option {
grid-template-columns: auto 1fr;
column-gap: var(--spacingHorizontalXS);
position: relative;
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);
cursor: pointer;
}
.thaw-tag-picker-option[data-activedescendant-focusvisible]::after {
content: "";
position: absolute;
right: -2px;
left: -2px;
bottom: -2px;
top: -2px;
z-index: 1;
pointer-events: none;
border-radius: var(--borderRadiusMedium);
border: 2px solid var(--colorStrokeFocus2);
}
.thaw-tag-picker-option:hover {
color: var(--colorNeutralForeground1Hover);
background-color: var(--colorNeutralBackground1Hover);
}
.thaw-tag-picker-option:active {
color: var(--colorNeutralForeground1Pressed);
background-color: var(--colorNeutralBackground1Pressed);
}
.thaw-tag-picker-option.thaw-tag-picker-option--disabled {
color: var(--colorNeutralForegroundDisabled);
}
.thaw-tag-picker-option--disabled:active,
.thaw-tag-picker-option--disabled:hover {
background-color: var(--colorTransparentBackground);
color: var(--colorNeutralForegroundDisabled);
}
.thaw-tag-picker-group {
display: inline-flex;
gap: var(--spacingHorizontalXS);
column-gap: var(--spacingHorizontalXS);
flex-wrap: wrap;
padding: var(--spacingVerticalSNudge) 0 var(--spacingVerticalSNudge) 0;
box-sizing: border-box;
cursor: text;
}

View file

@ -0,0 +1,199 @@
use crate::{
_aria::{use_active_descendant, ActiveDescendantController},
icon::ChevronDownRegularIcon,
listbox::{listbox_keyboard_event, Listbox},
};
use leptos::{context::Provider, ev, html, prelude::*};
use std::collections::HashMap;
use thaw_components::{Binder, Follower, FollowerPlacement, FollowerWidth};
use thaw_utils::{call_on_click_outside, class_list, mount_style, BoxCallback, Model};
#[component]
pub fn TagPicker(
#[prop(optional, into)] class: MaybeProp<String>,
/// An array of selected option keys.
#[prop(optional, into)]
selected_options: Model<Vec<String>>,
tag_picker_control: TagPickerControl,
children: Children,
) -> impl IntoView {
mount_style("tag-picker", include_str!("./tag-picker.css"));
let TagPickerControl {
children: control_children,
} = tag_picker_control;
let is_show_listbox = RwSignal::new(false);
let trigger_ref = NodeRef::<html::Div>::new();
let input_ref = NodeRef::<html::Input>::new();
let listbox_ref = NodeRef::<html::Div>::new();
let listbox_hidden_callback = StoredValue::new(vec![]);
let options = StoredValue::new(HashMap::<String, (String, String, MaybeSignal<bool>)>::new());
let (set_listbox, active_descendant_controller) =
use_active_descendant(move |el| el.class_list().contains("thaw-tag-picker-option"));
let tag_picker_control_injection =
TagPickerControlInjection(active_descendant_controller.clone());
let tag_picker_injection = TagPickerInjection {
selected_options,
input_ref,
options,
is_show_listbox,
listbox_hidden_callback,
};
let on_click = move |e: ev::MouseEvent| {
if e.default_prevented() {
if is_show_listbox.get() {
is_show_listbox.set(false);
}
return;
}
let Some(el) = input_ref.get_untracked() else {
return;
};
if document().active_element().as_ref() != Some(&**el) {
let _ = el.focus();
}
is_show_listbox.update(|show| *show = !*show);
};
call_on_click_outside(
trigger_ref,
{
move || {
is_show_listbox.set(false);
}
}
.into(),
);
let on_keydown = move |e| {
listbox_keyboard_event(
e,
is_show_listbox,
true,
&active_descendant_controller,
move |option| {
tag_picker_injection.options.with_value(|options| {
if let Some((value, _text, disabled)) = options.get(&option.id()) {
if disabled.get_untracked() {
return;
}
tag_picker_injection.select_option(value);
}
});
},
);
};
view! {
<Binder target_ref=trigger_ref>
<div
class=class_list!["thaw-tag-picker-control", class]
node_ref=trigger_ref
on:keydown=on_keydown
on:click=on_click
>
<Provider value=tag_picker_injection>
<Provider value=tag_picker_control_injection>{control_children()}</Provider>
</Provider>
<span class="thaw-tag-picker-control__aside">
<span class="thaw-tag-picker-control__expand-icon">
<ChevronDownRegularIcon />
</span>
</span>
</div>
<Follower
slot
show=is_show_listbox
placement=FollowerPlacement::BottomStart
width=FollowerWidth::MinTarget
>
<Provider value=tag_picker_injection>
<Listbox
open=is_show_listbox.read_only()
set_listbox
listbox_ref
on_hidden=listbox_hidden_callback
class="thaw-tag-picker__listbox"
>
{children()}
</Listbox>
</Provider>
</Follower>
</Binder>
}
}
#[slot]
pub struct TagPickerControl {
children: Children,
}
#[derive(Clone)]
pub(crate) struct TagPickerControlInjection(pub ActiveDescendantController);
impl TagPickerControlInjection {
pub fn expect_context() -> Self {
expect_context()
}
}
#[derive(Clone, Copy)]
pub(crate) struct TagPickerInjection {
pub input_ref: NodeRef<html::Input>,
selected_options: Model<Vec<String>>,
pub options: StoredValue<HashMap<String, (String, String, MaybeSignal<bool>)>>,
is_show_listbox: RwSignal<bool>,
listbox_hidden_callback: StoredValue<Vec<BoxCallback>>,
}
impl TagPickerInjection {
pub fn expect_context() -> Self {
expect_context()
}
/// value: (value, text, disabled)
pub fn insert_option(&self, id: String, value: (String, String, MaybeSignal<bool>)) {
self.options
.update_value(|options| options.insert(id, value));
}
pub fn remove_option(&self, id: &String) {
self.options.update_value(|options| options.remove(id));
}
pub fn is_selected(&self, value: &String) -> bool {
self.selected_options
.with(|options| options.contains(value))
}
pub fn select_option(&self, value: &String) {
self.selected_options.update(|options| {
if let Some(index) = options.iter().position(|v| v == value) {
options.remove(index);
} else {
options.push(value.clone());
}
});
self.is_show_listbox.set(false);
}
pub fn remove_selected_option(&self, value: String) {
if self.is_show_listbox.get_untracked() {
let selected_options = self.selected_options;
self.listbox_hidden_callback.update_value(|list| {
list.push(BoxCallback::new(move || {
selected_options.try_update(|options| {
if let Some(index) = options.iter().position(|v| v == &value) {
options.remove(index);
}
});
}));
});
} else {
self.selected_options.update(|options| {
if let Some(index) = options.iter().position(|v| v == &value) {
options.remove(index);
}
});
}
}
}

View file

@ -0,0 +1,26 @@
use super::TagPickerInjection;
use crate::{TagGroup, TagSize};
use leptos::prelude::*;
#[component]
pub fn TagPickerGroup(
#[prop(optional, into)] class: MaybeProp<String>,
children: Children,
) -> impl IntoView {
let tag_picker = TagPickerInjection::expect_context();
let class = MaybeProp::derive(move || {
Some(format!(
"thaw-tag-picker-group {}",
class.get().unwrap_or_default()
))
});
let on_dismiss = move |value| {
tag_picker.remove_selected_option(value);
};
view! {
<TagGroup attr:role="listbox" class size=TagSize::ExtraSmall dismissible=true on_dismiss>
{children()}
</TagGroup>
}
}

View file

@ -0,0 +1,55 @@
use super::{TagPickerControlInjection, TagPickerInjection};
use leptos::prelude::*;
use thaw_utils::class_list;
#[component]
pub fn TagPickerInput(#[prop(optional, into)] class: MaybeProp<String>) -> impl IntoView {
let TagPickerInjection {
input_ref, options, ..
} = TagPickerInjection::expect_context();
let TagPickerControlInjection(active_descendant_controller) =
TagPickerControlInjection::expect_context();
let value_trigger = ArcTrigger::new();
let on_blur = {
let value_trigger = value_trigger.clone();
move |_| {
value_trigger.track();
}
};
let on_input = move |ev| {
let value = event_target_value(&ev);
let value = value.trim().to_ascii_lowercase();
if value.is_empty() {
active_descendant_controller.blur();
return;
}
if active_descendant_controller
.find(|id| {
options.with_value(|options| {
let Some((_, text, _)) = options.get(&id) else {
return false;
};
text.to_ascii_lowercase().contains(&value)
})
})
.is_none()
{
active_descendant_controller.blur();
}
};
view! {
<input
node_ref=input_ref
type="text"
role="combobox"
class=class_list!["thaw-tag-picker-input", class]
on:blur=on_blur
on:input=on_input
prop:value=move || {
value_trigger.trigger();
""
}
/>
}
}

View file

@ -0,0 +1,66 @@
use super::TagPickerInjection;
use crate::listbox::ListboxInjection;
use leptos::{either::Either, ev, prelude::*};
use thaw_utils::class_list;
#[component]
pub fn TagPickerOption(
#[prop(optional, into)] class: MaybeProp<String>,
/// Sets an option to the disabled state.
#[prop(optional, into)]
disabled: MaybeSignal<bool>,
/// Defines a unique identifier for the option.
#[prop(into)]
value: String,
/// An optional override the string value of the Option's display text, defaulting to the Option's child content.
#[prop(into)]
text: String,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
let tag_picker = TagPickerInjection::expect_context();
let listbox = ListboxInjection::expect_context();
let value = StoredValue::new(value);
let text = StoredValue::new(text);
let is_selected = Memo::new(move |_| value.with_value(|value| tag_picker.is_selected(&value)));
let id = uuid::Uuid::new_v4().to_string();
let on_click = move |e: ev::MouseEvent| {
if disabled.get_untracked() {
e.stop_propagation();
return;
}
value.with_value(|value| {
tag_picker.select_option(value);
});
};
{
tag_picker.insert_option(id.clone(), (value.get_value(), text.get_value(), disabled));
let id = id.clone();
listbox.trigger();
on_cleanup(move || {
tag_picker.remove_option(&id);
listbox.trigger();
});
}
view! {
<div
role="option"
aria-disabled=move || if disabled.get() { "true" } else { "" }
aria-selected=move || is_selected.get().to_string()
id=id
class=class_list![
"thaw-tag-picker-option",
("thaw-tag-picker-option--selected", move || is_selected.get()),
("thaw-tag-picker-option--disabled", move || disabled.get()),
class
]
on:click=on_click
>
{if let Some(children) = children {
Either::Left(children())
} else {
Either::Right(text.get_value())
}}
</div>
}
}

View file

@ -1,28 +1,15 @@
use crate::BoxCallback;
use leptos::{html::Div, prelude::*};
use tachys::reactive_graph::node_ref::NodeRef;
pub fn call_on_click_outside(element: NodeRef<Div>, on_click: BoxCallback) {
#[cfg(any(feature = "csr", feature = "hydrate"))]
{
use leptos::ev;
let handle = window_event_listener(ev::click, move |ev| {
use leptos::wasm_bindgen::__rt::IntoJsResult;
let el = ev.target();
let mut el: Option<web_sys::Element> =
el.into_js_result().map_or(None, |el| Some(el.into()));
let body = document().body().unwrap();
while let Some(current_el) = el {
if current_el == *body {
break;
};
let Some(displayed_el) = element.get_untracked() else {
break;
};
if current_el == **displayed_el {
return;
}
el = current_el.parent_element();
let handle = window_event_listener(::leptos::ev::click, move |ev| {
let Some(displayed_el) = element.get_untracked() else {
return;
};
if ev.composed_path().includes(&displayed_el, 0) {
return;
}
on_click();
});