Skip to content

Commit d6dd048

Browse files
committed
feat(printer-settings): add modal with Tab 1 (Media & Feed)
First slice of the Printer Settings Modal epic. Ships the modal shell (vertical tab rail, instant-apply semantics, ZPL-command annotation per field) plus Tab 1 content covering four ZPL commands that the editor previously could not author: ^MN media tracking (continuous / web / mark / auto) ^ML max label length (dots) ^MF media feed (action at power up + after head close) ^XB suppress backfeed (toggle) Distinctive design move: every field label carries a small monospace ZPL-command tag docked right. Users see exactly which command each control emits, turning the modal into a discoverable spec reference without crowding the form. Layout: vertical tab rail (scales to N tabs, mirrors the Properties-Panel column-of-sections feel) with an active row indicator (▸ + orange accent + left border). Tabs 2 to 5 render an honest "coming soon" placeholder so the navigation tells the truth about scope. Modal shell uses the existing DialogShell (focus trap, body scroll lock, ARIA labelling, ESC + click-outside close) and renders via portal so the backdrop covers the full viewport. Persistence: all four fields write to the existing labelConfig store (no new printer-config store) so designs round-trip without a migration step and the Properties-Panel slim-down follow-up can simply move the panel's existing printer fields into the modal without re-plumbing storage. Parser + generator: header section in zplGenerator emits the four commands after the existing ^MM/^MT block. The corresponding parser handlers write to labelConfig and trim whitespace around positional params so `^MFF,L\n^XB` resolves both ^MF slots. Locales: new `printerSettings` block in all 32 locales (DE + EN curated by hand, the other 30 translated by a subagent run through the extended `update_locale_value.local.py`). Tests: emit + parser roundtrip coverage for each command, plus edge cases for out-of-range ^MN values and non-positive ^ML.
1 parent 54f5675 commit d6dd048

41 files changed

Lines changed: 1745 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/AppShell.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
DocumentArrowDownIcon,
2727
TableCellsIcon,
2828
PrinterIcon,
29+
Cog6ToothIcon,
2930
PaperAirplaneIcon,
3031
GlobeAltIcon,
3132
XMarkIcon,
@@ -34,6 +35,7 @@ import {
3435
} from "@heroicons/react/16/solid";
3536
import { useLabelStore, useHistory, selectLabelaryNoticeRequired } from "../store/labelStore";
3637
import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal";
38+
import { PrinterSettingsModal } from "./PrinterSettings/PrinterSettingsModal";
3739
import { localeNames } from "../locales";
3840
import type { LocaleCode } from "../locales";
3941
import { mmToUnit } from "../lib/units";
@@ -51,6 +53,7 @@ export function AppShell() {
5153
const pages = useLabelStore((s) => s.pages);
5254
const selectObject = useLabelStore((s) => s.selectObject);
5355
const addPage = useLabelStore((s) => s.addPage);
56+
const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab);
5457
const locale = useLabelStore((s) => s.locale);
5558
const setLocale = useLabelStore((s) => s.setLocale);
5659
const theme = useLabelStore((s) => s.theme);
@@ -205,6 +208,13 @@ export function AppShell() {
205208
<DropdownItem icon={ArrowUpTrayIcon} onClick={openZplImport}>
206209
{t.app.importZpl}
207210
</DropdownItem>
211+
<DropdownSeparator />
212+
<DropdownItem
213+
icon={Cog6ToothIcon}
214+
onClick={() => setPrinterSettingsTab("mediaFeed")}
215+
>
216+
{t.printerSettings.open}
217+
</DropdownItem>
208218
<DropdownItem
209219
icon={ArrowDownTrayIcon}
210220
onClick={handleDownload}
@@ -372,6 +382,7 @@ export function AppShell() {
372382
}}
373383
/>
374384
)}
385+
<PrinterSettingsModal />
375386
</div>
376387
);
377388
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { useId } from "react";
2+
import { useT } from "../../lib/useT";
3+
import { useLabelStore } from "../../store/labelStore";
4+
import { inputCls, labelCls } from "../Properties/styles";
5+
import {
6+
MEDIA_FEED_VALUES,
7+
MEDIA_TRACKING_VALUES,
8+
isMediaFeedMode,
9+
isMediaTracking,
10+
type MediaFeedMode,
11+
type MediaTracking,
12+
} from "../../types/ObjectType";
13+
import { ZplCheckbox, ZplCommandLabel, ZplField } from "./zplFieldPrimitives";
14+
15+
type LocMediaFeed = ReturnType<typeof useT>["printerSettings"]["mediaFeed"];
16+
17+
/** Static lookup table from enum value to the locale-key that
18+
* describes it. Compile-time check via `satisfies` guarantees
19+
* every enum value has a matching key and every key actually
20+
* exists on the locale block, so a missing translation surfaces
21+
* as a TS error rather than `undefined` at runtime. */
22+
const TRACKING_LABEL_KEYS = {
23+
N: "mediaTrackingN",
24+
Y: "mediaTrackingY",
25+
W: "mediaTrackingW",
26+
M: "mediaTrackingM",
27+
A: "mediaTrackingA",
28+
} as const satisfies Record<MediaTracking, keyof LocMediaFeed>;
29+
30+
const FEED_LABEL_KEYS = {
31+
F: "mediaFeedF",
32+
C: "mediaFeedC",
33+
L: "mediaFeedL",
34+
N: "mediaFeedN",
35+
S: "mediaFeedS",
36+
} as const satisfies Record<MediaFeedMode, keyof LocMediaFeed>;
37+
38+
/** Width preset for short-number inputs in the modal (max printer
39+
* ^ML value is ~32000 dots, so 5 digits cover every case). Same
40+
* visual size that every numeric setting in the modal will reuse. */
41+
const SHORT_NUMERIC_INPUT_WIDTH = "w-32";
42+
43+
/** Tab 1 of the Printer Settings Modal. All four fields write to
44+
* the shared `labelConfig` store; the ZPL generator emits the
45+
* corresponding ^MN / ^ML / ^MF / ^XB commands in the per-label
46+
* header section. */
47+
export function MediaFeedTab() {
48+
const t = useT();
49+
const label = useLabelStore((s) => s.label);
50+
const setLabelConfig = useLabelStore((s) => s.setLabelConfig);
51+
const loc = t.printerSettings.mediaFeed;
52+
const trackingId = useId();
53+
const lengthId = useId();
54+
55+
return (
56+
<div className="flex flex-col gap-4">
57+
<ZplField>
58+
<ZplCommandLabel text={loc.mediaTracking} command="^MN" htmlFor={trackingId} />
59+
<select
60+
id={trackingId}
61+
className={inputCls}
62+
value={label.mediaTracking ?? ""}
63+
onChange={(e) => {
64+
const v = e.target.value;
65+
setLabelConfig({ mediaTracking: isMediaTracking(v) ? v : undefined });
66+
}}
67+
>
68+
<option value="">{t.printerSettings.defaultOption}</option>
69+
{MEDIA_TRACKING_VALUES.map((m) => (
70+
<option key={m} value={m}>
71+
{m} {loc[TRACKING_LABEL_KEYS[m]]}
72+
</option>
73+
))}
74+
</select>
75+
</ZplField>
76+
77+
<ZplField>
78+
<ZplCommandLabel text={loc.maxLabelLength} command="^ML" htmlFor={lengthId} />
79+
{/* Compact input plus unit suffix: a short number in a wide
80+
field reads as overengineered. The suffix fills the
81+
remaining row width semantically rather than leaving
82+
dead space. */}
83+
<div className="flex items-center gap-2">
84+
<div className={SHORT_NUMERIC_INPUT_WIDTH}>
85+
<input
86+
id={lengthId}
87+
type="number"
88+
className={inputCls}
89+
value={label.maxLabelLength ?? ""}
90+
min={1}
91+
onChange={(e) => {
92+
const n = Number.parseInt(e.target.value, 10);
93+
setLabelConfig({
94+
maxLabelLength: Number.isFinite(n) && n > 0 ? n : undefined,
95+
});
96+
}}
97+
/>
98+
</div>
99+
<span className={labelCls}>{t.printerSettings.dotsUnit}</span>
100+
</div>
101+
</ZplField>
102+
103+
{/* ^MF carries two positional params, so the row's "control"
104+
slot is a 2-col grid instead of a single input. Same field
105+
shape as the other commands (label + control), not an
106+
indented sub-section. The indent convention is reserved
107+
for checkbox-gated sub-settings (see FELDBLOCK), not for
108+
commands with multiple params. */}
109+
<ZplField>
110+
<ZplCommandLabel text={loc.mediaFeedHeading} command="^MF" />
111+
<div className="grid grid-cols-2 gap-2">
112+
<FeedSelect
113+
label={loc.mediaFeedPowerUp}
114+
value={label.mediaFeedPowerUp}
115+
onChange={(v) => setLabelConfig({ mediaFeedPowerUp: v })}
116+
placeholder={t.printerSettings.defaultOption}
117+
optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]}
118+
/>
119+
<FeedSelect
120+
label={loc.mediaFeedHeadClose}
121+
value={label.mediaFeedHeadClose}
122+
onChange={(v) => setLabelConfig({ mediaFeedHeadClose: v })}
123+
placeholder={t.printerSettings.defaultOption}
124+
optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]}
125+
/>
126+
</div>
127+
</ZplField>
128+
129+
<ZplCheckbox
130+
text={loc.suppressBackfeed}
131+
command="^XB"
132+
checked={!!label.suppressBackfeed}
133+
onChange={(v) => setLabelConfig({ suppressBackfeed: v ? true : undefined })}
134+
/>
135+
</div>
136+
);
137+
}
138+
139+
interface FeedSelectProps {
140+
label: string;
141+
value: MediaFeedMode | undefined;
142+
onChange: (v: MediaFeedMode | undefined) => void;
143+
placeholder: string;
144+
optionLabel: (m: MediaFeedMode) => string;
145+
}
146+
147+
/** Inner select-with-ZPL-label helper. Extracted so the two ^MF
148+
* positional params don't repeat the same select scaffolding.
149+
* Uses `useId` instead of caller-supplied IDs so multi-instance
150+
* mounting can't collide. */
151+
function FeedSelect({ label, value, onChange, placeholder, optionLabel }: FeedSelectProps) {
152+
const id = useId();
153+
return (
154+
<div className="flex flex-col gap-1">
155+
<label htmlFor={id} className={labelCls}>
156+
{label}
157+
</label>
158+
<select
159+
id={id}
160+
className={inputCls}
161+
value={value ?? ""}
162+
onChange={(e) => {
163+
const v = e.target.value;
164+
onChange(isMediaFeedMode(v) ? v : undefined);
165+
}}
166+
>
167+
<option value="">{placeholder}</option>
168+
{MEDIA_FEED_VALUES.map((m) => (
169+
<option key={m} value={m}>
170+
{m} {optionLabel(m)}
171+
</option>
172+
))}
173+
</select>
174+
</div>
175+
);
176+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useId, type FC } from "react";
2+
import { XMarkIcon } from "@heroicons/react/16/solid";
3+
import { useT } from "../../lib/useT";
4+
import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore";
5+
import { DialogShell } from "../ui/DialogShell";
6+
import { MediaFeedTab } from "./MediaFeedTab";
7+
8+
/** Tab IDs in display order. The "is this tab shipped?" state is
9+
* derived from TAB_COMPONENTS membership below, so adding a tab
10+
* is a one-line registry entry plus the locale strings — no dual
11+
* source of truth to keep in sync. */
12+
const TABS: readonly PrinterSettingsTab[] = [
13+
"mediaFeed",
14+
"printQuality",
15+
"clockTime",
16+
"encodingLanguage",
17+
"identity",
18+
];
19+
20+
/** Per-tab content registry. Tabs absent here render as disabled
21+
* WIP rail entries; the rail prevents selection so the modal
22+
* never has to handle a missing-component branch. Annotated
23+
* (not `satisfies`) so `TAB_COMPONENTS[tab]` returns `FC |
24+
* undefined` for any tab id without a narrowing cast. */
25+
const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = {
26+
mediaFeed: MediaFeedTab,
27+
};
28+
29+
/** Modal-box class kept as a constant rather than inline so the
30+
* width / shadow tuning is named and stays consistent if other
31+
* modals adopt the same shell. */
32+
const MODAL_BOX_CLS =
33+
"bg-surface border border-border rounded-lg shadow-2xl " +
34+
"w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden";
35+
36+
/** Printer Settings modal — five tabs of pure-ZPL printer config.
37+
* Vertical tab rail scales to N tabs and mirrors the
38+
* Properties-Panel column-of-sections feel. Instant-apply
39+
* semantics: edits commit live to labelConfig (no Save/Cancel),
40+
* matching the rest of the editor. Each field's ZPL command sits
41+
* docked rightwards via `ZplCommandLabel`. */
42+
export function PrinterSettingsModal() {
43+
const t = useT();
44+
const tab = useLabelStore((s) => s.printerSettingsTab);
45+
const setTab = useLabelStore((s) => s.setPrinterSettingsTab);
46+
const titleId = useId();
47+
const subtitleId = useId();
48+
49+
if (!tab) return null;
50+
51+
const ActiveTab = TAB_COMPONENTS[tab];
52+
53+
return (
54+
<DialogShell
55+
portal
56+
labelledBy={titleId}
57+
describedBy={subtitleId}
58+
onClose={() => setTab(null)}
59+
boxClassName={MODAL_BOX_CLS}
60+
>
61+
<header className="px-5 py-4 border-b border-border flex items-start justify-between gap-4">
62+
<div className="flex flex-col gap-0.5">
63+
<h2 id={titleId} className="text-sm font-medium text-text">
64+
{t.printerSettings.title}
65+
</h2>
66+
<p id={subtitleId} className="text-[11px] text-muted">
67+
{t.printerSettings.subtitle}
68+
</p>
69+
</div>
70+
<button
71+
type="button"
72+
onClick={() => setTab(null)}
73+
aria-label={t.printerSettings.close}
74+
className="text-muted hover:text-text transition-colors p-1 -m-1"
75+
>
76+
<XMarkIcon className="w-4 h-4" />
77+
</button>
78+
</header>
79+
80+
<div className="flex-1 flex min-h-0">
81+
<nav
82+
// Tab-rail width fits the longest localised tab title at
83+
// 10px-uppercase tracking-wider density across all 32
84+
// locales (worst case: "Kodierung & Sprache" / "Encoding
85+
// & Language").
86+
className="w-44 shrink-0 border-r border-border bg-surface-2/40 py-3"
87+
aria-label={t.printerSettings.title}
88+
>
89+
{TABS.map((id) => {
90+
const active = id === tab;
91+
// WIP is derived: a tab is WIP iff no content
92+
// component is registered for it. Single source of
93+
// truth, no flag to keep in sync.
94+
const wip = !(id in TAB_COMPONENTS);
95+
const railRowCls = active
96+
? "text-accent border-accent -ml-px bg-accent/5"
97+
: wip
98+
? "text-muted/40 border-transparent cursor-not-allowed"
99+
: "text-muted border-transparent hover:text-text hover:bg-surface-2";
100+
return (
101+
<button
102+
key={id}
103+
type="button"
104+
onClick={() => setTab(id)}
105+
disabled={wip}
106+
aria-current={active ? "page" : undefined}
107+
className={
108+
"w-full text-left flex items-center justify-between gap-2 px-3 py-1.5 " +
109+
"font-mono text-[10px] uppercase tracking-wider " +
110+
"border-l-2 transition-colors " +
111+
railRowCls
112+
}
113+
>
114+
<span>{t.printerSettings.tabs[id]}</span>
115+
{wip && <span className="text-[9px] text-muted/50">{t.printerSettings.wip}</span>}
116+
</button>
117+
);
118+
})}
119+
</nav>
120+
121+
<section className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
122+
{ActiveTab && <ActiveTab />}
123+
</section>
124+
</div>
125+
126+
<footer className="px-5 py-3 border-t border-border flex items-center justify-between gap-4">
127+
<span className="text-[11px] text-muted">
128+
{t.printerSettings.instantApply}
129+
</span>
130+
<button
131+
type="button"
132+
onClick={() => setTab(null)}
133+
className="px-4 py-1.5 rounded text-xs bg-surface-2 text-text border border-border hover:bg-surface transition-colors"
134+
>
135+
{t.printerSettings.close}
136+
</button>
137+
</footer>
138+
</DialogShell>
139+
);
140+
}

0 commit comments

Comments
 (0)