Repository navigation
feat(printer-settings): add modal with Tab 1 (Media & Feed) #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import { useId } from "react"; | ||
| import { useT } from "../../lib/useT"; | ||
| import { useLabelStore } from "../../store/labelStore"; | ||
| import { inputCls, labelCls } from "../Properties/styles"; | ||
| import { | ||
| MEDIA_FEED_VALUES, | ||
| MEDIA_TRACKING_VALUES, | ||
| isMediaFeedMode, | ||
| isMediaTracking, | ||
| type MediaFeedMode, | ||
| type MediaTracking, | ||
| } from "../../types/ObjectType"; | ||
| import { ZplCheckbox, ZplCommandLabel, ZplField } from "./zplFieldPrimitives"; | ||
|
|
||
| type LocMediaFeed = ReturnType<typeof useT>["printerSettings"]["mediaFeed"]; | ||
|
|
||
| /** Static lookup table from enum value to the locale-key that | ||
| * describes it. Compile-time check via `satisfies` guarantees | ||
| * every enum value has a matching key and every key actually | ||
| * exists on the locale block, so a missing translation surfaces | ||
| * as a TS error rather than `undefined` at runtime. */ | ||
| const TRACKING_LABEL_KEYS = { | ||
| N: "mediaTrackingN", | ||
| Y: "mediaTrackingY", | ||
| W: "mediaTrackingW", | ||
| M: "mediaTrackingM", | ||
| A: "mediaTrackingA", | ||
| } as const satisfies Record<MediaTracking, keyof LocMediaFeed>; | ||
|
|
||
| const FEED_LABEL_KEYS = { | ||
| F: "mediaFeedF", | ||
| C: "mediaFeedC", | ||
| L: "mediaFeedL", | ||
| N: "mediaFeedN", | ||
| S: "mediaFeedS", | ||
| } as const satisfies Record<MediaFeedMode, keyof LocMediaFeed>; | ||
|
|
||
| /** Width preset for short-number inputs in the modal (max printer | ||
| * ^ML value is ~32000 dots, so 5 digits cover every case). Same | ||
| * visual size that every numeric setting in the modal will reuse. */ | ||
| const SHORT_NUMERIC_INPUT_WIDTH = "w-32"; | ||
|
|
||
| /** Tab 1 of the Printer Settings Modal. All four fields write to | ||
| * the shared `labelConfig` store; the ZPL generator emits the | ||
| * corresponding ^MN / ^ML / ^MF / ^XB commands in the per-label | ||
| * header section. */ | ||
| export function MediaFeedTab() { | ||
| const t = useT(); | ||
| const label = useLabelStore((s) => s.label); | ||
| const setLabelConfig = useLabelStore((s) => s.setLabelConfig); | ||
| const loc = t.printerSettings.mediaFeed; | ||
| const trackingId = useId(); | ||
| const lengthId = useId(); | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-4"> | ||
| <ZplField> | ||
| <ZplCommandLabel text={loc.mediaTracking} command="^MN" htmlFor={trackingId} /> | ||
| <select | ||
| id={trackingId} | ||
| className={inputCls} | ||
| value={label.mediaTracking ?? ""} | ||
| onChange={(e) => { | ||
| const v = e.target.value; | ||
| setLabelConfig({ mediaTracking: isMediaTracking(v) ? v : undefined }); | ||
| }} | ||
| > | ||
| <option value="">{t.printerSettings.defaultOption}</option> | ||
| {MEDIA_TRACKING_VALUES.map((m) => ( | ||
| <option key={m} value={m}> | ||
| {m} {loc[TRACKING_LABEL_KEYS[m]]} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| </ZplField> | ||
|
|
||
| <ZplField> | ||
| <ZplCommandLabel text={loc.maxLabelLength} command="^ML" htmlFor={lengthId} /> | ||
| {/* Compact input plus unit suffix: a short number in a wide | ||
| field reads as overengineered. The suffix fills the | ||
| remaining row width semantically rather than leaving | ||
| dead space. */} | ||
| <div className="flex items-center gap-2"> | ||
| <div className={SHORT_NUMERIC_INPUT_WIDTH}> | ||
| <input | ||
| id={lengthId} | ||
| type="number" | ||
| className={inputCls} | ||
| value={label.maxLabelLength ?? ""} | ||
| min={1} | ||
| onChange={(e) => { | ||
| const n = Number.parseInt(e.target.value, 10); | ||
| setLabelConfig({ | ||
| maxLabelLength: Number.isFinite(n) && n > 0 ? n : undefined, | ||
| }); | ||
| }} | ||
| /> | ||
| </div> | ||
| <span className={labelCls}>{t.printerSettings.dotsUnit}</span> | ||
| </div> | ||
| </ZplField> | ||
|
|
||
| {/* ^MF carries two positional params, so the row's "control" | ||
| slot is a 2-col grid instead of a single input. Same field | ||
| shape as the other commands (label + control), not an | ||
| indented sub-section. The indent convention is reserved | ||
| for checkbox-gated sub-settings (see FELDBLOCK), not for | ||
| commands with multiple params. */} | ||
| <ZplField> | ||
| <ZplCommandLabel text={loc.mediaFeedHeading} command="^MF" /> | ||
| <div className="grid grid-cols-2 gap-2"> | ||
| <FeedSelect | ||
| label={loc.mediaFeedPowerUp} | ||
| value={label.mediaFeedPowerUp} | ||
| onChange={(v) => setLabelConfig({ mediaFeedPowerUp: v })} | ||
| placeholder={t.printerSettings.defaultOption} | ||
| optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]} | ||
| /> | ||
| <FeedSelect | ||
| label={loc.mediaFeedHeadClose} | ||
| value={label.mediaFeedHeadClose} | ||
| onChange={(v) => setLabelConfig({ mediaFeedHeadClose: v })} | ||
| placeholder={t.printerSettings.defaultOption} | ||
| optionLabel={(m) => loc[FEED_LABEL_KEYS[m]]} | ||
| /> | ||
| </div> | ||
| </ZplField> | ||
|
|
||
| <ZplCheckbox | ||
| text={loc.suppressBackfeed} | ||
| command="^XB" | ||
| checked={!!label.suppressBackfeed} | ||
| onChange={(v) => setLabelConfig({ suppressBackfeed: v ? true : undefined })} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface FeedSelectProps { | ||
| label: string; | ||
| value: MediaFeedMode | undefined; | ||
| onChange: (v: MediaFeedMode | undefined) => void; | ||
| placeholder: string; | ||
| optionLabel: (m: MediaFeedMode) => string; | ||
| } | ||
|
|
||
| /** Inner select-with-ZPL-label helper. Extracted so the two ^MF | ||
| * positional params don't repeat the same select scaffolding. | ||
| * Uses `useId` instead of caller-supplied IDs so multi-instance | ||
| * mounting can't collide. */ | ||
| function FeedSelect({ label, value, onChange, placeholder, optionLabel }: FeedSelectProps) { | ||
| const id = useId(); | ||
| return ( | ||
| <div className="flex flex-col gap-1"> | ||
| <label htmlFor={id} className={labelCls}> | ||
| {label} | ||
| </label> | ||
| <select | ||
| id={id} | ||
| className={inputCls} | ||
| value={value ?? ""} | ||
| onChange={(e) => { | ||
| const v = e.target.value; | ||
| onChange(isMediaFeedMode(v) ? v : undefined); | ||
| }} | ||
| > | ||
| <option value="">{placeholder}</option> | ||
| {MEDIA_FEED_VALUES.map((m) => ( | ||
| <option key={m} value={m}> | ||
| {m} {optionLabel(m)} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| </div> | ||
| ); | ||
| } |
140 changes: 140 additions & 0 deletions
140
src/components/PrinterSettings/PrinterSettingsModal.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { useId, type FC } from "react"; | ||
| import { XMarkIcon } from "@heroicons/react/16/solid"; | ||
| import { useT } from "../../lib/useT"; | ||
| import { useLabelStore, type PrinterSettingsTab } from "../../store/labelStore"; | ||
| import { DialogShell } from "../ui/DialogShell"; | ||
| import { MediaFeedTab } from "./MediaFeedTab"; | ||
|
|
||
| /** Tab IDs in display order. The "is this tab shipped?" state is | ||
| * derived from TAB_COMPONENTS membership below, so adding a tab | ||
| * is a one-line registry entry plus the locale strings — no dual | ||
| * source of truth to keep in sync. */ | ||
| const TABS: readonly PrinterSettingsTab[] = [ | ||
| "mediaFeed", | ||
| "printQuality", | ||
| "clockTime", | ||
| "encodingLanguage", | ||
| "identity", | ||
| ]; | ||
|
|
||
| /** Per-tab content registry. Tabs absent here render as disabled | ||
| * WIP rail entries; the rail prevents selection so the modal | ||
| * never has to handle a missing-component branch. Annotated | ||
| * (not `satisfies`) so `TAB_COMPONENTS[tab]` returns `FC | | ||
| * undefined` for any tab id without a narrowing cast. */ | ||
| const TAB_COMPONENTS: Partial<Record<PrinterSettingsTab, FC>> = { | ||
| mediaFeed: MediaFeedTab, | ||
| }; | ||
|
|
||
| /** Modal-box class kept as a constant rather than inline so the | ||
| * width / shadow tuning is named and stays consistent if other | ||
| * modals adopt the same shell. */ | ||
| const MODAL_BOX_CLS = | ||
| "bg-surface border border-border rounded-lg shadow-2xl " + | ||
| "w-[720px] max-w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"; | ||
|
|
||
| /** Printer Settings modal — five tabs of pure-ZPL printer config. | ||
| * Vertical tab rail scales to N tabs and mirrors the | ||
| * Properties-Panel column-of-sections feel. Instant-apply | ||
| * semantics: edits commit live to labelConfig (no Save/Cancel), | ||
| * matching the rest of the editor. Each field's ZPL command sits | ||
| * docked rightwards via `ZplCommandLabel`. */ | ||
| export function PrinterSettingsModal() { | ||
| const t = useT(); | ||
| const tab = useLabelStore((s) => s.printerSettingsTab); | ||
| const setTab = useLabelStore((s) => s.setPrinterSettingsTab); | ||
| const titleId = useId(); | ||
| const subtitleId = useId(); | ||
|
|
||
| if (!tab) return null; | ||
|
|
||
| const ActiveTab = TAB_COMPONENTS[tab]; | ||
|
|
||
| return ( | ||
| <DialogShell | ||
| portal | ||
| labelledBy={titleId} | ||
| describedBy={subtitleId} | ||
| onClose={() => setTab(null)} | ||
| boxClassName={MODAL_BOX_CLS} | ||
| > | ||
| <header className="px-5 py-4 border-b border-border flex items-start justify-between gap-4"> | ||
| <div className="flex flex-col gap-0.5"> | ||
| <h2 id={titleId} className="text-sm font-medium text-text"> | ||
| {t.printerSettings.title} | ||
| </h2> | ||
| <p id={subtitleId} className="text-[11px] text-muted"> | ||
| {t.printerSettings.subtitle} | ||
| </p> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| onClick={() => setTab(null)} | ||
| aria-label={t.printerSettings.close} | ||
| className="text-muted hover:text-text transition-colors p-1 -m-1" | ||
| > | ||
| <XMarkIcon className="w-4 h-4" /> | ||
| </button> | ||
| </header> | ||
|
|
||
| <div className="flex-1 flex min-h-0"> | ||
| <nav | ||
| // Tab-rail width fits the longest localised tab title at | ||
| // 10px-uppercase tracking-wider density across all 32 | ||
| // locales (worst case: "Kodierung & Sprache" / "Encoding | ||
| // & Language"). | ||
| className="w-44 shrink-0 border-r border-border bg-surface-2/40 py-3" | ||
| aria-label={t.printerSettings.title} | ||
| > | ||
| {TABS.map((id) => { | ||
| const active = id === tab; | ||
| // WIP is derived: a tab is WIP iff no content | ||
| // component is registered for it. Single source of | ||
| // truth, no flag to keep in sync. | ||
| const wip = !(id in TAB_COMPONENTS); | ||
| const railRowCls = active | ||
| ? "text-accent border-accent -ml-px bg-accent/5" | ||
| : wip | ||
| ? "text-muted/40 border-transparent cursor-not-allowed" | ||
| : "text-muted border-transparent hover:text-text hover:bg-surface-2"; | ||
| return ( | ||
| <button | ||
| key={id} | ||
| type="button" | ||
| onClick={() => setTab(id)} | ||
| disabled={wip} | ||
| aria-current={active ? "page" : undefined} | ||
| className={ | ||
| "w-full text-left flex items-center justify-between gap-2 px-3 py-1.5 " + | ||
| "font-mono text-[10px] uppercase tracking-wider " + | ||
| "border-l-2 transition-colors " + | ||
| railRowCls | ||
| } | ||
| > | ||
| <span>{t.printerSettings.tabs[id]}</span> | ||
| {wip && <span className="text-[9px] text-muted/50">{t.printerSettings.wip}</span>} | ||
| </button> | ||
| ); | ||
| })} | ||
| </nav> | ||
|
|
||
| <section className="flex-1 min-h-0 overflow-y-auto px-5 py-4"> | ||
| {ActiveTab && <ActiveTab />} | ||
| </section> | ||
| </div> | ||
|
|
||
| <footer className="px-5 py-3 border-t border-border flex items-center justify-between gap-4"> | ||
| <span className="text-[11px] text-muted"> | ||
| {t.printerSettings.instantApply} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={() => setTab(null)} | ||
| className="px-4 py-1.5 rounded text-xs bg-surface-2 text-text border border-border hover:bg-surface transition-colors" | ||
| > | ||
| {t.printerSettings.close} | ||
| </button> | ||
| </footer> | ||
| </DialogShell> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.