Skip to main content

gpui_kit_material/components/
text.rs

1//! Text roles that carry their accessibility semantics.
2use crate::color::ColorRole;
3use crate::icons::{Icon, IconName};
4use crate::theme::color;
5use crate::tokens::{layout::*, shape::RADIUS_CARD, typography::*};
6use gpui::{prelude::*, *};
7
8/// Text on a leaf element with a label role. GPUI's accessibility bridge
9/// does not expose plain `Div` text, and keeping labels on leaves stops an
10/// ancestor from replacing its controls' accessibility.
11pub fn accessible_text(id: impl Into<ElementId>, value: impl Into<SharedString>) -> Stateful<Div> {
12    let value = value.into();
13    let label = spoken(&value);
14    div()
15        .id(id)
16        .role(Role::Label)
17        .aria_label(label)
18        .child(value)
19}
20
21/// The accessible name of displayed text. Word joiners only steer line
22/// wrapping; a screen reader gets the plain words so its pacing matches the
23/// text's meaning.
24fn spoken(value: &SharedString) -> SharedString {
25    if value.contains('\u{2060}') {
26        SharedString::from(value.replace('\u{2060}', ""))
27    } else {
28        value.clone()
29    }
30}
31
32/// Failure feedback as one accessible alert form. Callers chain their own
33/// size and wrapping.
34pub fn error_text(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Stateful<Div> {
35    accessible_text(id, message)
36        .role(Role::Alert)
37        .text_color(color(ColorRole::ERROR))
38}
39
40/// An icon column and a medium-weight text column. The slot also takes a
41/// live spinner, keeping state changes on the same axis.
42pub fn semantic_label(
43    id: impl Into<ElementId>,
44    label: impl Into<SharedString>,
45    icon: impl IntoElement,
46) -> Div {
47    div()
48        .flex()
49        .items_center()
50        .min_w_0()
51        .gap(ICON_GAP)
52        .child(
53            div()
54                .flex()
55                .items_center()
56                .justify_center()
57                .size(ICON_SLOT)
58                .flex_shrink_0()
59                .child(icon),
60        )
61        .child(
62            accessible_text(id, label)
63                .min_w_0()
64                .whitespace_normal()
65                .text_size(TEXT_BODY)
66                .font_weight(FontWeight::MEDIUM),
67        )
68}
69
70/// Short supporting text inside an existing item boundary. The item
71/// supplies the surface; a second frame would compete with it. The info
72/// icon sits in a label's icon slot, so the text starts on the label ruler.
73pub fn supporting_info(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Div {
74    div()
75        .flex()
76        .w_full()
77        .min_w_0()
78        .items_start()
79        .gap(ICON_GAP)
80        .child(
81            div()
82                .flex()
83                .justify_center()
84                .w(ICON_SLOT)
85                .h(rem(18.))
86                .items_center()
87                .flex_shrink_0()
88                .child(
89                    IconName::Info
90                        .icon()
91                        .size(rem(16.))
92                        .color(color(ColorRole::ON_SURFACE_VARIANT)),
93                ),
94        )
95        .child(
96            accessible_text(id, text)
97                .flex_1()
98                .min_w_0()
99                .whitespace_normal()
100                .text_size(TEXT_AUX)
101                .line_height(rem(18.))
102                .text_color(color(ColorRole::ON_SURFACE_VARIANT)),
103        )
104}
105
106/// Standalone guidance with its own low-emphasis boundary. Inside an item,
107/// use [`supporting_info`].
108pub fn info_callout(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Div {
109    div()
110        .flex()
111        .w_full()
112        .min_w_0()
113        .items_start()
114        .gap(rem(10.))
115        .px(rem(16.))
116        .py(rem(12.))
117        .rounded(RADIUS_CARD)
118        .bg(color(ColorRole::SURFACE_CONTAINER_HIGHEST))
119        .child(
120            div().mt(rem(1.)).child(
121                IconName::Info
122                    .icon()
123                    .size(rem(18.))
124                    .color(color(ColorRole::ON_SURFACE_VARIANT)),
125            ),
126        )
127        .child(
128            accessible_text(id, text)
129                .flex_1()
130                .min_w_0()
131                .whitespace_normal()
132                .text_size(TEXT_AUX)
133                .line_height(rem(18.))
134                .text_color(color(ColorRole::ON_SURFACE_VARIANT)),
135        )
136}
137
138/// A page title: text alone in the regular-weight headline role. A title
139/// longer than its column wraps rather than running past the edge.
140pub fn page_heading(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Div {
141    div().flex().items_center().min_w_0().child(
142        accessible_text(id, title)
143            .min_w_0()
144            .whitespace_normal()
145            .role(Role::Heading)
146            .text_size(TEXT_HEADLINE)
147            .line_height(HEADLINE_LINE_HEIGHT)
148            .font_weight(FontWeight::NORMAL),
149    )
150}
151
152/// An M3 list section title: a quiet medium-weight label above the group
153/// it names, on the group's text inset.
154pub fn section_title(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Stateful<Div> {
155    accessible_text(id, title)
156        .role(Role::Heading)
157        .text_size(TEXT_BODY)
158        .line_height(rem(20.))
159        .font_weight(FontWeight::MEDIUM)
160        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
161}
162
163/// A section title with a leading icon.
164pub fn section_heading(
165    id: impl Into<ElementId>,
166    icon: impl Into<Icon>,
167    title: impl Into<SharedString>,
168) -> Div {
169    div()
170        .flex()
171        .min_w_0()
172        .items_center()
173        .gap(ICON_GAP)
174        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
175        .child(icon.into().size(rem(18.)))
176        .child(section_title(id, title))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use core::prelude::v1::test;
183
184    #[test]
185    fn spoken_text_drops_word_joiners_only() {
186        let joined = SharedString::from("Text\u{2060}to\u{2060}speech");
187        assert_eq!(spoken(&joined).as_ref(), "Texttospeech");
188        let plain = SharedString::from("Plain words");
189        assert_eq!(spoken(&plain), plain);
190    }
191}