mirror of
https://github.com/adoyle0/thaw.git
synced 2025-01-22 14:09:21 -05:00
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:
parent
1ab9dd4c9f
commit
583b03100e
22 changed files with 1121 additions and 64 deletions
|
@ -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/>
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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` | | |
|
||||
|
|
57
thaw/src/tag/docs/tag_group/mod.md
Normal file
57
thaw/src/tag/docs/tag_group/mod.md
Normal 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` | | |
|
72
thaw/src/tag/interaction-tag.css
Normal file
72
thaw/src/tag/interaction-tag.css
Normal 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);
|
||||
}
|
44
thaw/src/tag/interaction_tag.rs
Normal file
44
thaw/src/tag/interaction_tag.rs
Normal 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>
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
4
thaw/src/tag/tag-group.css
Normal file
4
thaw/src/tag/tag-group.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.thaw-tag-group {
|
||||
display: inline-flex;
|
||||
column-gap: var(--spacingHorizontalS);
|
||||
}
|
|
@ -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
43
thaw/src/tag/tag_group.rs
Normal 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()
|
||||
}
|
||||
}
|
70
thaw/src/tag_picker/docs/mod.md
Normal file
70
thaw/src/tag_picker/docs/mod.md
Normal 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` | |
|
9
thaw/src/tag_picker/mod.rs
Normal file
9
thaw/src/tag_picker/mod.rs
Normal 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::*;
|
156
thaw/src/tag_picker/tag-picker.css
Normal file
156
thaw/src/tag_picker/tag-picker.css
Normal 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;
|
||||
}
|
199
thaw/src/tag_picker/tag_picker.rs
Normal file
199
thaw/src/tag_picker/tag_picker.rs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
26
thaw/src/tag_picker/tag_picker_group.rs
Normal file
26
thaw/src/tag_picker/tag_picker_group.rs
Normal 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>
|
||||
}
|
||||
}
|
55
thaw/src/tag_picker/tag_picker_input.rs
Normal file
55
thaw/src/tag_picker/tag_picker_input.rs
Normal 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();
|
||||
""
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
66
thaw/src/tag_picker/tag_picker_option.rs
Normal file
66
thaw/src/tag_picker/tag_picker_option.rs
Normal 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>
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue