Skip to content

Commit d0ace1d

Browse files
Add onscreen keyboard support for item and category editing
1 parent 4ce74ab commit d0ace1d

3 files changed

Lines changed: 116 additions & 6 deletions

File tree

frontend/src/pages/categories.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use uuid::Uuid;
33

44
use crate::i18n::I18n;
55
use crate::models::*;
6+
use crate::pages::keyboard::OnScreenKeyboard;
67
use crate::server_fns::*;
78

89
#[component]
@@ -29,6 +30,29 @@ pub fn CategoriesPage() -> impl IntoView {
2930
let (name, set_name) = signal(String::new());
3031
let (description, set_description) = signal(String::new());
3132

33+
// On-screen keyboard target: "name" or "description" (hidden on mobile via CSS)
34+
let (kb_target, set_kb_target) = signal(Option::<String>::None);
35+
let (kb_shift, set_kb_shift) = signal(false);
36+
37+
let on_kb_key = move |key: String| {
38+
let Some(target) = kb_target.get() else { return };
39+
let setter = match target.as_str() {
40+
"name" => set_name,
41+
"description" => set_description,
42+
_ => return,
43+
};
44+
match key.as_str() {
45+
"Backspace" => { setter.update(|s| { s.pop(); }); }
46+
"Enter" => { set_kb_target.set(None); }
47+
"Shift" => { set_kb_shift.update(|s| *s = !*s); }
48+
"Space" => { setter.update(|s| s.push(' ')); }
49+
ch => {
50+
let ch = if kb_shift.get() { ch.to_uppercase() } else { ch.to_lowercase() };
51+
setter.update(|s| s.push_str(&ch));
52+
}
53+
}
54+
};
55+
3256
// Trigger signal: incrementing this causes the Effect to re-run
3357
let (reload, set_reload) = signal(0u32);
3458

@@ -40,6 +64,7 @@ pub fn CategoriesPage() -> impl IntoView {
4064
});
4165

4266
let start_edit = move |category: Category| {
67+
set_kb_target.set(None);
4368
set_name.set(category.name.clone());
4469
set_description.set(category.description.clone().unwrap_or_default());
4570
set_editing_category.set(Some(category));
@@ -83,10 +108,12 @@ pub fn CategoriesPage() -> impl IntoView {
83108
};
84109
let cancel_delete = move |_| { set_deleting_category.set(None); };
85110
let cancel_edit = move |_| {
111+
set_kb_target.set(None);
86112
set_editing_category.set(None); set_creating_category.set(false);
87113
set_name.set(String::new()); set_description.set(String::new());
88114
};
89115
let start_create = move |_| {
116+
set_kb_target.set(None);
90117
set_name.set(String::new()); set_description.set(String::new());
91118
set_creating_category.set(true); set_editing_category.set(None);
92119
};
@@ -129,13 +156,28 @@ pub fn CategoriesPage() -> impl IntoView {
129156
<div class="form-grid">
130157
<div class="form-group">
131158
<label>{move || i18n.get().t("general.name")}</label>
132-
<input type="text" value=move || name.get() on:input=move |ev| set_name.set(event_target_value(&ev)) />
159+
<div class="admin-input-row">
160+
<input type="text" value=move || name.get() on:input=move |ev| set_name.set(event_target_value(&ev)) />
161+
<button class="btn-secondary-small" type="button" on:click=move |_| {
162+
if kb_target.get() == Some("name".into()) { set_kb_target.set(None); }
163+
else { set_kb_target.set(Some("name".into())); set_kb_shift.set(false); }
164+
}>{move || if kb_target.get() == Some("name".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
165+
</div>
133166
</div>
134167
<div class="form-group">
135168
<label>{move || i18n.get().t("general.description")}</label>
136-
<input type="text" value=move || description.get() on:input=move |ev| set_description.set(event_target_value(&ev)) />
169+
<div class="admin-input-row">
170+
<input type="text" value=move || description.get() on:input=move |ev| set_description.set(event_target_value(&ev)) />
171+
<button class="btn-secondary-small" type="button" on:click=move |_| {
172+
if kb_target.get() == Some("description".into()) { set_kb_target.set(None); }
173+
else { set_kb_target.set(Some("description".into())); set_kb_shift.set(false); }
174+
}>{move || if kb_target.get() == Some("description".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
175+
</div>
137176
</div>
138177
</div>
178+
<Show when=move || kb_target.get().is_some() fallback=|| ()>
179+
<OnScreenKeyboard on_key=on_kb_key shift=kb_shift i18n=i18n />
180+
</Show>
139181
<div class="form-actions">
140182
<button class="btn-success" on:click=save_category>{move || i18n.get().t("general.save")}</button>
141183
<button class="btn-secondary" on:click=cancel_edit>{move || i18n.get().t("general.cancel")}</button>

frontend/src/pages/items.rs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use uuid::Uuid;
33

44
use crate::i18n::I18n;
55
use crate::models::*;
6+
use crate::pages::keyboard::OnScreenKeyboard;
67
use crate::server_fns::*;
78

89
#[cfg(not(target_arch = "wasm32"))]
@@ -90,6 +91,41 @@ pub fn ItemsPage() -> impl IntoView {
9091
let (stock_quantity, set_stock_quantity) = signal(String::new());
9192
let (kitchen_item, set_kitchen_item) = signal(false);
9293

94+
// On-screen keyboard target: "name", "description" or "sku" (hidden on mobile via CSS)
95+
let (kb_target, set_kb_target) = signal(Option::<String>::None);
96+
let (kb_shift, set_kb_shift) = signal(false);
97+
98+
let on_kb_key = move |key: String| {
99+
let Some(target) = kb_target.get() else { return };
100+
let numeric = target == "price";
101+
let setter = match target.as_str() {
102+
"name" => set_name,
103+
"description" => set_description,
104+
"sku" => set_sku,
105+
"price" => set_price,
106+
_ => return,
107+
};
108+
match key.as_str() {
109+
"Backspace" => { setter.update(|s| { s.pop(); }); }
110+
"Enter" => { set_kb_target.set(None); }
111+
"Shift" => { set_kb_shift.update(|s| *s = !*s); }
112+
"Space" => { if !numeric { setter.update(|s| s.push(' ')); } }
113+
ch => {
114+
if numeric {
115+
// Price accepts digits and a single decimal point only
116+
if ch.chars().all(|c| c.is_ascii_digit()) {
117+
setter.update(|s| s.push_str(ch));
118+
} else if ch == "." || ch == "," {
119+
setter.update(|s| if !s.contains('.') { s.push('.'); });
120+
}
121+
} else {
122+
let ch = if kb_shift.get() { ch.to_uppercase() } else { ch.to_lowercase() };
123+
setter.update(|s| s.push_str(&ch));
124+
}
125+
}
126+
}
127+
};
128+
93129
let (reload, set_reload) = signal(0u32);
94130

95131
Effect::new(move || {
@@ -101,6 +137,7 @@ pub fn ItemsPage() -> impl IntoView {
101137
});
102138

103139
let start_edit = move |item: Item| {
140+
set_kb_target.set(None);
104141
set_name.set(item.name.clone());
105142
set_description.set(item.description.clone().unwrap_or_default());
106143
set_price.set(item.price.to_string());
@@ -180,6 +217,7 @@ pub fn ItemsPage() -> impl IntoView {
180217
};
181218
let cancel_delete = move |_| { set_deleting_item.set(None); };
182219
let cancel_edit = move |_| {
220+
set_kb_target.set(None);
183221
set_editing_item.set(None); set_creating_item.set(false);
184222
set_name.set(String::new()); set_description.set(String::new());
185223
set_price.set(String::new()); set_category_id.set(String::new());
@@ -188,6 +226,7 @@ pub fn ItemsPage() -> impl IntoView {
188226
set_stock_quantity.set(String::new()); set_kitchen_item.set(false);
189227
};
190228
let start_create = move |_| {
229+
set_kb_target.set(None);
191230
set_name.set(String::new()); set_description.set(String::new());
192231
set_price.set(String::new());
193232
set_category_id.set(if let Some(cat) = categories.get().first() { cat.id.to_string() } else { String::new() });
@@ -239,11 +278,23 @@ pub fn ItemsPage() -> impl IntoView {
239278
<div class="form-grid">
240279
<div class="form-group">
241280
<label>{move || i18n.get().t("general.name")}</label>
242-
<input type="text" value=move || name.get() on:input=move |ev| set_name.set(event_target_value(&ev)) />
281+
<div class="admin-input-row">
282+
<input type="text" value=move || name.get() on:input=move |ev| set_name.set(event_target_value(&ev)) />
283+
<button class="btn-secondary-small" type="button" on:click=move |_| {
284+
if kb_target.get() == Some("name".into()) { set_kb_target.set(None); }
285+
else { set_kb_target.set(Some("name".into())); set_kb_shift.set(false); }
286+
}>{move || if kb_target.get() == Some("name".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
287+
</div>
243288
</div>
244289
<div class="form-group">
245290
<label>{move || i18n.get().t("items.price")}</label>
246-
<input type="number" step="0.01" value=move || price.get() on:input=move |ev| set_price.set(event_target_value(&ev)) />
291+
<div class="admin-input-row">
292+
<input type="text" inputmode="decimal" value=move || price.get() on:input=move |ev| set_price.set(event_target_value(&ev)) />
293+
<button class="btn-secondary-small" type="button" on:click=move |_| {
294+
if kb_target.get() == Some("price".into()) { set_kb_target.set(None); }
295+
else { set_kb_target.set(Some("price".into())); set_kb_shift.set(false); }
296+
}>{move || if kb_target.get() == Some("price".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
297+
</div>
247298
</div>
248299
<div class="form-group">
249300
<label>{move || i18n.get().t("items.category")}</label>
@@ -255,11 +306,23 @@ pub fn ItemsPage() -> impl IntoView {
255306
</div>
256307
<div class="form-group">
257308
<label>{move || i18n.get().t("items.sku")}</label>
258-
<input type="text" value=move || sku.get() on:input=move |ev| set_sku.set(event_target_value(&ev)) />
309+
<div class="admin-input-row">
310+
<input type="text" value=move || sku.get() on:input=move |ev| set_sku.set(event_target_value(&ev)) />
311+
<button class="btn-secondary-small" type="button" on:click=move |_| {
312+
if kb_target.get() == Some("sku".into()) { set_kb_target.set(None); }
313+
else { set_kb_target.set(Some("sku".into())); set_kb_shift.set(false); }
314+
}>{move || if kb_target.get() == Some("sku".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
315+
</div>
259316
</div>
260317
<div class="form-group">
261318
<label>{move || i18n.get().t("general.description")}</label>
262-
<input type="text" value=move || description.get() on:input=move |ev| set_description.set(event_target_value(&ev)) />
319+
<div class="admin-input-row">
320+
<input type="text" value=move || description.get() on:input=move |ev| set_description.set(event_target_value(&ev)) />
321+
<button class="btn-secondary-small" type="button" on:click=move |_| {
322+
if kb_target.get() == Some("description".into()) { set_kb_target.set(None); }
323+
else { set_kb_target.set(Some("description".into())); set_kb_shift.set(false); }
324+
}>{move || if kb_target.get() == Some("description".into()) { i18n.get().t("admin.hide_kb") } else { i18n.get().t("admin.keyboard") }}</button>
325+
</div>
263326
</div>
264327
<div class="form-group">
265328
<label>
@@ -296,6 +359,9 @@ pub fn ItemsPage() -> impl IntoView {
296359
</Show>
297360
</div>
298361
</div>
362+
<Show when=move || kb_target.get().is_some() fallback=|| ()>
363+
<OnScreenKeyboard on_key=on_kb_key shift=kb_shift i18n=i18n />
364+
</Show>
299365
<div class="form-actions">
300366
<button class="btn-success" on:click=save_item>{move || i18n.get().t("general.save")}</button>
301367
<button class="btn-secondary" on:click=cancel_edit>{move || i18n.get().t("general.cancel")}</button>

frontend/src/pages/keyboard.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ pub fn OnScreenKeyboard(
6363
}}
6464
<div class="osk-row">
6565
{
66+
let on_key_dot = on_key;
6667
let on_key_space = on_key;
6768
let on_key_enter = on_key;
6869
view! {
70+
<button class="osk-key" on:click=move |_| on_key_dot(".".into())>"."</button>
6971
<button class="osk-key osk-key-space" on:click=move |_| on_key_space("Space".into())>{move || i18n.get().t("keyboard.space")}</button>
7072
<button class="osk-key osk-key-wide" on:click=move |_| on_key_enter("Enter".into())>{move || i18n.get().t("keyboard.enter")}</button>
7173
}

0 commit comments

Comments
 (0)