From fa86009e3fafa238780b2cebeaca184da075208e Mon Sep 17 00:00:00 2001 From: fs Date: Fri, 24 Apr 2026 21:26:11 +0200 Subject: [PATCH] feat(ui): copyable input partial + dynamic copy-target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New core partial templates/partials/app-input-copy.phtml renders a standard label + input with a compact copy button overlaid on the right edge of the input. Used for privacy/imprint URLs on the tenant form; reusable for any field where a one-click copy is helpful. - Extend the shared copy-field component (web/js/components/app-copy-field.js) with data-copy-target support — the button now reads the target input's .value at click-time, so users can edit a field and still copy the current value. Static data-copy-value keeps working unchanged. - New component CSS web/css/components/app-input-copy.css positions the button absolute/inset + margin-block:auto (robust vertical centering regardless of input height) and uses a descendant selector (0,2,0) so the button wins over the global [data-tooltip] position:relative. - Also register app-input-copy.css + app-tenant-logo.css in core.css @import list — they were only in the shared asset group before, which default-template admin pages don't load, so tenant-logo styles were effectively missing on admin tenant edit (topbar size etc.). - Document the Copy-to-Clipboard + Copyable Input standards in docs/reference-frontend-javascript.md so future consumers don't re-invent the markup/selectors. - File-upload preview: pending-file block restructured to a compact list-item row (small square thumb + filename/size stack + clear X) and removed the transparency checker pattern on the current-image preview in favour of a flat --app-preview-bg surface. - Tenant edit page title + breadcrumb now show the tenant description ("Acme GmbH") instead of the generic "Mandant bearbeiten" when one is present. - i18n: add "Copy to clipboard" / "In Zwischenablage kopieren" across both locales. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/assets.php | 1 - docs/reference-frontend-javascript.md | 79 +++++++++++++++++++++++++ i18n/default_de.json | 1 + i18n/default_en.json | 1 + pages/admin/tenants/_form.phtml | 26 ++++---- pages/admin/tenants/edit($id).php | 4 +- pages/admin/tenants/edit(default).phtml | 5 +- templates/partials/app-input-copy.phtml | 51 ++++++++++++++++ web/css/components/app-file-upload.css | 35 +++++------ web/css/components/app-input-copy.css | 73 +++++++++++++++++++++++ web/css/core.css | 2 + web/js/components/app-copy-field.js | 25 +++++--- 12 files changed, 262 insertions(+), 41 deletions(-) create mode 100644 templates/partials/app-input-copy.phtml create mode 100644 web/css/components/app-input-copy.css diff --git a/config/assets.php b/config/assets.php index 8ffefc1..409f8fd 100644 --- a/config/assets.php +++ b/config/assets.php @@ -18,7 +18,6 @@ return [ 'css/components/app-brand.css', 'css/components/app-file-upload.css', 'css/components/app-footer.css', - 'css/components/app-tenant-logo.css', ], 'core' => [ 'css/core.css', diff --git a/docs/reference-frontend-javascript.md b/docs/reference-frontend-javascript.md index 05b1f4f..84931b1 100644 --- a/docs/reference-frontend-javascript.md +++ b/docs/reference-frontend-javascript.md @@ -306,6 +306,85 @@ Für standardisierte Detailseiten gilt zusätzlich: - Aktionssemantik über `data-detail-action-kind` (z. B. `save`, `save-close`, `delete`, `danger`). - Optionaler Lock-Override über `data-detail-submit-lock="none"` (Default: `form`). +### Copy-to-Clipboard Standard + +Für kopierbare Inhalte (Werte, Feld-Inhalte, Badges) gilt eine einzige +Component: `web/js/components/app-copy-field.js` — ein Button mit +`data-copy-button` bekommt automatisch einen Click-Handler, der in die +Zwischenablage schreibt und 1,2 s lang ein Check-Icon statt des Copy-Icons +anzeigt (Klasse `is-copied` wird am Button getoggelt). + +Zwei Quell-Modi: + +- **Statischer Wert** (fix im Markup): + ```html + + ``` + +- **Live Input-Wert** (liest `input.value` beim Klick): + ```html + + + ``` + Der Live-Modus ist vorzuziehen, wenn der User das Feld editieren kann — + der Button kopiert dann immer den aktuellen Wert, nicht den beim ersten + Render gesetzten. + +### Copyable Input Standard + +Für Text-Inputs, bei denen ein Copy-Button *im* Feld sitzen soll (z. B. URLs, +IDs, Tokens) existiert die Core-Partial **`templates/partials/app-input-copy.phtml`**. + +Verwendung im Form: + +```php + +``` + +Optionale Vars: `$id` (Default = `$name`), `$placeholder`, `$required`, +`$disabled`. Die Partial rendert: + +```html + +``` + +CSS-Kontrakt (`web/css/components/app-input-copy.css`): + +- `.app-input-copy` — Positioning-Wrapper, `position: relative` +- `.app-input-copy > input` — Native Form-Styling, `padding-right: 2.5rem` + für den Overlay-Button +- `.app-input-copy > .app-input-copy-button` — absolute positioniert rechts, + vertikal zentriert via `inset 0/auto + margin-block: auto`. Spezifität + `(0,2,0)` beim Descendant-Selector nötig, damit die globale + `[data-tooltip] { position: relative }`-Regel nicht gewinnt. +- `.is-copied` (wird vom Copy-Field-Component gesetzt) — tauscht Copy-Icon + gegen Check, färbt Button in `--app-action-success-border`. + +Nicht selbst die Markup-Struktur nachbauen — immer die Partial verwenden, +damit Spacing, Vertical-Centering und A11y (`aria-label`, Tooltip) konsistent +bleiben. + ### Global Confirm Actions Für allgemeine Form-/Button-Confirms außerhalb der Detail-Policy gilt: diff --git a/i18n/default_de.json b/i18n/default_de.json index 8d77617..5502f44 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -604,6 +604,7 @@ "English": "Englisch", "Switch tenant": "Mandant wechseln", "Copy": "Kopieren", + "Copy to clipboard": "In Zwischenablage kopieren", "Registration successful! Please check your email for the verification code.": "Registrierung erfolgreich! Bitte prüfe deine E-Mails für den Bestätigungscode.", "Registration is currently disabled": "Registrierung ist derzeit deaktiviert.", "A new verification code has been sent.": "Ein neuer Bestätigungscode wurde gesendet.", diff --git a/i18n/default_en.json b/i18n/default_en.json index d372196..1b0ece0 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -604,6 +604,7 @@ "English": "English", "Switch tenant": "Switch tenant", "Copy": "Copy", + "Copy to clipboard": "Copy to clipboard", "Registration successful! Please check your email for the verification code.": "Registration successful! Please check your email for the verification code.", "Registration is currently disabled": "Registration is currently disabled.", "A new verification code has been sent.": "A new verification code has been sent.", diff --git a/pages/admin/tenants/_form.phtml b/pages/admin/tenants/_form.phtml index a9170d9..93823d7 100644 --- a/pages/admin/tenants/_form.phtml +++ b/pages/admin/tenants/_form.phtml @@ -310,16 +310,22 @@ $openOverrideCard = $detailsOpenAll;
- - + +
diff --git a/pages/admin/tenants/edit($id).php b/pages/admin/tenants/edit($id).php index 52a552e..cde72f8 100644 --- a/pages/admin/tenants/edit($id).php +++ b/pages/admin/tenants/edit($id).php @@ -181,7 +181,9 @@ if ($canManageSso && empty($ldapUiState)) { $validationSummaryErrors = $errorBag->toArray(); $errors = $errorBag->toFlatList(); -$titleText = $canUpdateTenant ? t('Edit tenant') : t('View tenant'); +$tenantLabel = trim((string) ($form['description'] ?? $tenant['description'] ?? '')); +$fallbackTitle = $canUpdateTenant ? t('Edit tenant') : t('View tenant'); +$titleText = $tenantLabel !== '' ? $tenantLabel : $fallbackTitle; Buffer::set('title', $titleText); $breadcrumbs = [ ['label' => t('Home'), 'path' => 'admin'], diff --git a/pages/admin/tenants/edit(default).phtml b/pages/admin/tenants/edit(default).phtml index 40c54da..c6a2dd0 100644 --- a/pages/admin/tenants/edit(default).phtml +++ b/pages/admin/tenants/edit(default).phtml @@ -15,7 +15,10 @@ $canDeleteTenant = (bool) ($pageAuth['can_delete_tenant'] ?? false); $canManageCustomFields = (bool) ($pageAuth['can_manage_custom_fields'] ?? false); $canManageSso = (bool) ($pageAuth['can_manage_sso'] ?? false); $isReadOnly = !$canUpdateTenant; -$titleText = $isReadOnly ? t('View tenant') : t('Edit tenant'); +$tenantDescription = trim((string) ($values['description'] ?? '')); +$titleText = $tenantDescription !== '' + ? $tenantDescription + : ($isReadOnly ? t('View tenant') : t('Edit tenant')); $tenantUuid = (string) ($values['uuid'] ?? ''); $tenantLogoService = app(\MintyPHP\Service\Tenant\TenantLogoService::class); $tenantFaviconService = app(\MintyPHP\Service\Tenant\TenantFaviconService::class); diff --git a/templates/partials/app-input-copy.phtml b/templates/partials/app-input-copy.phtml new file mode 100644 index 0000000..bf32e00 --- /dev/null +++ b/templates/partials/app-input-copy.phtml @@ -0,0 +1,51 @@ + + above, input with a + * compact copy button overlaid on the right edge. A
wrapper around + * the input+button provides a dedicated positioning context so nothing + * leaks from the label's layout rules. + * + * The copy button reads the input's CURRENT value (live) via + * data-copy-target so users can edit the field and still copy the new value. + * + * Required view vars: + * $name — input name attribute + * $label — already-translated label text + * + * Optional: + * $id, $value, $type (default 'text'), $placeholder, + * $readonly, $required, $disabled + */ + +$inputName = (string) ($name ?? ''); +if ($inputName === '') { + return; +} +$inputId = (string) ($id ?? $inputName); +$inputType = (string) ($type ?? 'text'); +$inputValue = (string) ($value ?? ''); +$inputLabel = (string) ($label ?? ''); +$inputPlaceholder = (string) ($placeholder ?? ''); +$inputReadonly = (bool) ($readonly ?? false); +$inputRequired = (bool) ($required ?? false); +$inputDisabled = (bool) ($disabled ?? false); +?> + diff --git a/web/css/components/app-file-upload.css b/web/css/components/app-file-upload.css index 3949c41..b3246c2 100644 --- a/web/css/components/app-file-upload.css +++ b/web/css/components/app-file-upload.css @@ -169,11 +169,11 @@ } /* ── Pending file preview (visible when new file selected) ── - Same layout as current: full-width thumb + info row below. */ + Compact row: small thumb (left) + filename/size stack (middle) + clear X (right). */ .app-file-upload-preview { display: none; - flex-direction: column; - gap: calc(var(--app-spacing) * 0.5); + align-items: center; + gap: calc(var(--app-spacing) * 0.75); padding: calc(var(--app-spacing) * 0.75); } @@ -182,53 +182,50 @@ } .app-file-upload-thumbnail { - width: 100%; - aspect-ratio: 16 / 9; - padding: var(--app-spacing); + width: 48px; + height: 48px; border-radius: calc(var(--app-border-radius) * 0.5); - object-fit: contain; - object-position: center; + object-fit: cover; border: 1px solid var(--app-muted-border-color); background-color: var(--app-preview-bg); - box-sizing: border-box; + flex-shrink: 0; } .app-file-upload-file-icon { - width: 100%; - aspect-ratio: 16 / 9; + width: 48px; + height: 48px; border-radius: calc(var(--app-border-radius) * 0.5); display: inline-flex; align-items: center; justify-content: center; - font-size: var(--text-3xl); + font-size: var(--text-xl); color: var(--app-muted-color); - background: var(--app-background-color); + background: var(--app-preview-bg); border: 1px solid var(--app-muted-border-color); + flex-shrink: 0; } .app-file-upload-meta { display: flex; - align-items: center; - justify-content: space-between; - gap: calc(var(--app-spacing) * 0.5); + flex-direction: column; + gap: 2px; + flex: 1; min-width: 0; } .app-file-upload-filename { font-size: var(--text-sm); font-weight: var(--font-medium); - color: var(--app-contrast); + color: var(--app-color); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - flex: 1; } .app-file-upload-filesize { font-size: var(--text-xs); color: var(--app-muted-color); - flex-shrink: 0; } .app-file-upload-clear { diff --git a/web/css/components/app-input-copy.css b/web/css/components/app-input-copy.css new file mode 100644 index 0000000..fb60b99 --- /dev/null +++ b/web/css/components/app-input-copy.css @@ -0,0 +1,73 @@ +@layer components { + /* Copyable input — absolute-positioned button overlay on the right edge + of a native-width input. Wrapper is a plain block so it fits into + any grid cell without overflow; button is positioned absolute and + vertically centered via the inset + margin-block:auto pattern. */ + .app-input-copy { + position: relative; + } + + .app-input-copy > input { + padding-right: 2.5rem; + width: 100%; + display: block; + } + + /* High specificity (0,2,0) to beat [data-tooltip] (0,1,0) which sets + position: relative on the copy button (tooltip host). */ + .app-input-copy > .app-input-copy-button { + position: absolute; + top: 0; + bottom: 0; + right: 0.3rem; + margin-block: auto; + height: fit-content; + background: transparent; + border: 0; + box-shadow: none; + color: var(--app-muted-color); + cursor: pointer; + padding: 0.3rem 0.45rem; + width: auto; + line-height: var(--leading-none); + border-radius: calc(var(--app-border-radius) * 0.5); + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, background-color 0.15s ease; + } + + .app-input-copy > .app-input-copy-button:hover { + color: var(--app-primary); + background: color-mix(in srgb, var(--app-primary) 10%, transparent); + } + + .app-input-copy > .app-input-copy-button:focus-visible { + outline: 2px solid var(--app-primary); + outline-offset: 1px; + } + + .app-input-copy > .app-input-copy-button [data-copy-icon-done] { + display: none; + } + + .app-input-copy > .app-input-copy-button.is-copied { + color: var(--app-action-success-border); + background: color-mix(in srgb, var(--app-action-success-border) 12%, transparent); + } + + .app-input-copy > .app-input-copy-button.is-copied [data-copy-icon-idle] { + display: none; + } + + .app-input-copy > .app-input-copy-button.is-copied [data-copy-icon-done] { + display: inline-flex; + } + + /* Input inside the wrapper is now the last child of label (no longer + a direct child). Add the margin-top that the global + label > input rule would have applied. */ + label > .app-input-copy { + margin-top: calc(var(--app-spacing) * 0.25); + } +} diff --git a/web/css/core.css b/web/css/core.css index a6a22ef..74a0c5e 100644 --- a/web/css/core.css +++ b/web/css/core.css @@ -15,6 +15,8 @@ @import url("components/app-flash.css"); @import url("components/app-brand.css"); @import url("components/app-file-upload.css"); +@import url("components/app-input-copy.css"); +@import url("components/app-tenant-logo.css"); @import url("components/app-footer.css"); /* Default admin experience */ diff --git a/web/js/components/app-copy-field.js b/web/js/components/app-copy-field.js index 0fa91f7..964b481 100644 --- a/web/js/components/app-copy-field.js +++ b/web/js/components/app-copy-field.js @@ -1,13 +1,13 @@ /** - * Copy-to-clipboard button — click writes `data-copy-value` to the clipboard - * and swaps the button icon to a check for ~1.2s. Self-contained: markup is - * fully rendered server-side, this component only wires the click handler. + * Copy-to-clipboard button — click writes a value to the clipboard and swaps + * the button icon to a check for ~1.2s. Self-contained: markup is fully + * rendered server-side, this component only wires the click handler. * - * + * Static value (read from the button itself): + * + * + * Live input value (resolved at click-time from the target element's .value): + * */ import { resolveHost } from '../core/app-dom.js'; @@ -71,7 +71,14 @@ export function initCopyField(root = document, options = {}) { const onClick = async (event) => { event.preventDefault(); - const value = button.dataset.copyValue || ''; + const targetId = (button.dataset.copyTarget || '').trim(); + let value = button.dataset.copyValue || ''; + if (targetId) { + const target = document.getElementById(targetId); + if (target && 'value' in target) { + value = /** @type {HTMLInputElement} */ (target).value; + } + } const ok = await copyText(value); if (!ok) {return;} button.classList.add(DONE_CLASS);