Skip to content

Commit 4a97f8c

Browse files
committed
fix: improve form alignment, responsiveness, and signature pad polish
- Add forms.css with centralized form styling loaded from base.html: - Side-by-side field alignment: flex-column layout in .row columns ensures inputs align vertically regardless of label length, help text presence, or wrapped text - Consistent field-wrapper spacing, label weight, help text sizing - Mobile responsive stacking: columns stack on <768px with proper gap spacing - Checkbox/radio padding improvements - Required-field asterisk styling - Overhaul signature-pad.js for responsive, world-class UX: - Canvas now fills 100% of container width (fully responsive) - 2x backing store on retina/HiDPI displays for crisp signatures - Debounced resize handler preserves drawing on window resize - Better coordinate mapping using CSS-pixel space - Modern focus-ring on hover/active (blue glow matching Bootstrap) - Subtle background color (#fafbfc) and refined border styling - Pass help_text as data-help-text attribute on signature hidden inputs so the JS can render it below the canvas - Remove duplicate inline signature CSS from form_submit.html and approve.html (now centralized in forms.css) - Bump version to 0.40.1
1 parent 89b7dd0 commit 4a97f8c

File tree

7 files changed

+198
-64
lines changed

7 files changed

+198
-64
lines changed

django_forms_workflows/forms.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -664,10 +664,11 @@ def add_field(self, field_def, initial_data):
664664
# data URI from an HTML canvas. A hidden input stores the value;
665665
# the visible canvas widget is injected by JavaScript keyed on
666666
# the ``data-signature-field`` attribute.
667+
sig_attrs = {"data-signature-field": field_def.field_name}
668+
if field_def.help_text:
669+
sig_attrs["data-help-text"] = field_def.help_text
667670
self.fields[field_def.field_name] = forms.CharField(
668-
widget=forms.HiddenInput(
669-
attrs={"data-signature-field": field_def.field_name}
670-
),
671+
widget=forms.HiddenInput(attrs=sig_attrs),
671672
required=field_def.required,
672673
label=field_def.field_label,
673674
help_text=field_def.help_text,
@@ -1459,10 +1460,11 @@ def _create_field(self, field_def, field_args, widget_attrs, is_editable):
14591460
)
14601461

14611462
elif field_def.field_type == "signature":
1463+
sig_attrs = {"data-signature-field": field_def.field_name}
1464+
if field_def.help_text:
1465+
sig_attrs["data-help-text"] = field_def.help_text
14621466
self.fields[field_def.field_name] = forms.CharField(
1463-
widget=forms.HiddenInput(
1464-
attrs={"data-signature-field": field_def.field_name}
1465-
),
1467+
widget=forms.HiddenInput(attrs=sig_attrs),
14661468
required=field_args.get("required", False),
14671469
label=field_def.field_label,
14681470
help_text=field_def.help_text,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/* ═══════════════════════════════════════════════════════════════════════════
2+
forms.css — Alignment, spacing & responsive polish for django-forms-workflows
3+
═══════════════════════════════════════════════════════════════════════════ */
4+
5+
/* ── Side-by-side field alignment ────────────────────────────────────────
6+
When two half-width fields sit in a .row, the shorter one needs to
7+
bottom-align its input with the taller one regardless of label length,
8+
help-text presence, or wrapped text. We use flex-column + stretch so
9+
the label area grows evenly and the input sits at a consistent position. */
10+
11+
.row.align-items-start > [class*="col-"] > .mb-3,
12+
.row.align-items-start > .field-wrapper > .mb-3 {
13+
display: flex;
14+
flex-direction: column;
15+
height: 100%;
16+
}
17+
.row.align-items-start > [class*="col-"] > .mb-3 > label,
18+
.row.align-items-start > .field-wrapper > .mb-3 > label {
19+
flex-shrink: 0;
20+
}
21+
.row.align-items-start > [class*="col-"] > .mb-3 > .form-control,
22+
.row.align-items-start > [class*="col-"] > .mb-3 > .form-select,
23+
.row.align-items-start > .field-wrapper > .mb-3 > .form-control,
24+
.row.align-items-start > .field-wrapper > .mb-3 > .form-select {
25+
margin-top: auto; /* push the input to the bottom of the cell */
26+
}
27+
/* Help text sits *after* the input and shouldn't shift it up */
28+
.row.align-items-start > [class*="col-"] > .mb-3 > .form-text,
29+
.row.align-items-start > .field-wrapper > .mb-3 > .form-text {
30+
flex-shrink: 0;
31+
min-height: 1.4em; /* reserve space even when empty for alignment */
32+
}
33+
34+
/* ── Consistent field spacing ────────────────────────────────────────── */
35+
36+
.field-wrapper {
37+
margin-bottom: 1rem;
38+
}
39+
/* Slightly more breathing room between label and input */
40+
.field-wrapper label.form-label,
41+
.field-wrapper label.form-check-label {
42+
margin-bottom: 0.375rem;
43+
font-weight: 500;
44+
}
45+
/* Help text polish */
46+
.field-wrapper .form-text {
47+
font-size: 0.8125rem;
48+
margin-top: 0.375rem;
49+
line-height: 1.4;
50+
}
51+
52+
/* ── Responsive stacking ─────────────────────────────────────────────── */
53+
54+
@media (max-width: 767.98px) {
55+
/* Stack side-by-side fields vertically on mobile */
56+
.row > [class*="col-md-"] {
57+
margin-bottom: 0.75rem;
58+
}
59+
/* Give the form card a tighter horizontal padding on small screens */
60+
.card-body {
61+
padding-left: 1rem;
62+
padding-right: 1rem;
63+
}
64+
}
65+
66+
/* ── Signature Pad ───────────────────────────────────────────────────── */
67+
68+
.signature-pad-wrapper {
69+
margin-top: 0.25rem;
70+
}
71+
.signature-pad-wrapper > label {
72+
display: block;
73+
font-weight: 500;
74+
margin-bottom: 0.5rem;
75+
}
76+
.signature-pad-canvas-container {
77+
display: block;
78+
width: 100%;
79+
border: 2px solid #dee2e6;
80+
border-radius: 0.5rem;
81+
background: #fafbfc;
82+
cursor: crosshair;
83+
touch-action: none;
84+
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
85+
}
86+
.signature-pad-canvas-container:hover,
87+
.signature-pad-canvas-container:active {
88+
border-color: #86b7fe;
89+
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
90+
}
91+
.signature-pad-canvas {
92+
display: block;
93+
width: 100%;
94+
height: auto;
95+
border-radius: 0.5rem;
96+
}
97+
.signature-pad-buttons {
98+
margin-top: 0.5rem;
99+
display: flex;
100+
gap: 0.5rem;
101+
align-items: center;
102+
}
103+
.signature-pad-help {
104+
font-size: 0.8125rem;
105+
color: #6c757d;
106+
margin-top: 0.375rem;
107+
}
108+
109+
/* ── Form submit button row ──────────────────────────────────────────── */
110+
111+
.form-actions {
112+
margin-top: 1.5rem;
113+
padding-top: 1rem;
114+
border-top: 1px solid #e9ecef;
115+
}
116+
117+
/* ── Checkbox / radio alignment ──────────────────────────────────────── */
118+
119+
.form-check {
120+
padding-top: 0.25rem;
121+
padding-bottom: 0.25rem;
122+
}
123+
124+
/* ── Required field asterisk ─────────────────────────────────────────── */
125+
126+
.asteriskField {
127+
color: #dc3545;
128+
font-weight: 600;
129+
margin-left: 0.15em;
130+
}
131+

django_forms_workflows/static/django_forms_workflows/js/signature-pad.js

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
/**
2-
* Signature Pad — lightweight canvas-based signature capture.
2+
* Signature Pad — lightweight, responsive canvas-based signature capture.
33
*
44
* For every <input type="hidden" data-signature-field="FIELD_NAME"> this
55
* script injects a visible canvas, a "Clear" button, and optional label /
66
* help text. When the user draws on the canvas the base64 PNG data URI is
77
* written back to the hidden input so it is submitted with the form.
88
*
9+
* The canvas fills 100% of its container width and uses a 2× backing store
10+
* on high-DPI screens for crisp signatures.
11+
*
912
* Usage: include this script on any page that renders a form with
1013
* signature fields. It auto-initialises on DOMContentLoaded.
1114
*/
1215
(function () {
1316
'use strict';
1417

18+
// Logical (CSS) height of the signing area — the canvas will always be
19+
// as wide as its container and this many CSS-pixels tall.
20+
var CANVAS_CSS_HEIGHT = 140;
21+
1522
function initSignaturePad(hiddenInput) {
1623
var fieldName = hiddenInput.getAttribute('data-signature-field');
1724
if (!fieldName) return;
@@ -24,11 +31,9 @@
2431
var wrapper = document.createElement('div');
2532
wrapper.className = 'signature-pad-wrapper mb-3';
2633

27-
// Label (pulled from the hidden input's associated <label> if any)
34+
// Label — reuse the existing <label> created by crispy-forms
2835
var existingLabel = document.querySelector('label[for="id_' + fieldName + '"]');
2936
if (!existingLabel) {
30-
// Crispy forms may not produce a label for hidden inputs, so we
31-
// look for one in the parent .mb-3 / .field-wrapper container.
3237
var parent = hiddenInput.closest('.field-wrapper, .mb-3');
3338
if (parent) existingLabel = parent.querySelector('label');
3439
}
@@ -38,51 +43,82 @@
3843
label.textContent = existingLabel ? existingLabel.textContent : 'Signature';
3944
if (hiddenInput.required) {
4045
var asterisk = document.createElement('span');
41-
asterisk.className = 'text-danger';
46+
asterisk.className = 'asteriskField';
4247
asterisk.textContent = ' *';
4348
label.appendChild(asterisk);
4449
}
4550
wrapper.appendChild(label);
4651

47-
// Canvas container (gives the border / background)
52+
// Canvas container — will stretch to 100% of wrapper width via CSS
4853
var canvasContainer = document.createElement('div');
4954
canvasContainer.className = 'signature-pad-canvas-container';
5055
wrapper.appendChild(canvasContainer);
5156

5257
var canvas = document.createElement('canvas');
53-
canvas.width = 500;
54-
canvas.height = 160;
5558
canvas.className = 'signature-pad-canvas';
5659
canvasContainer.appendChild(canvas);
5760

5861
// Buttons
5962
var btnBar = document.createElement('div');
60-
btnBar.className = 'signature-pad-buttons mt-1';
63+
btnBar.className = 'signature-pad-buttons';
6164
var clearBtn = document.createElement('button');
6265
clearBtn.type = 'button';
6366
clearBtn.className = 'btn btn-sm btn-outline-secondary';
6467
clearBtn.innerHTML = '<i class="bi bi-eraser"></i> Clear';
6568
btnBar.appendChild(clearBtn);
6669
wrapper.appendChild(btnBar);
6770

68-
// Help text
71+
// Help text (from the form field's help_text)
6972
var helpText = hiddenInput.getAttribute('data-help-text');
7073
if (helpText) {
7174
var helpEl = document.createElement('div');
72-
helpEl.className = 'form-text text-muted';
75+
helpEl.className = 'signature-pad-help';
7376
helpEl.textContent = helpText;
7477
wrapper.appendChild(helpEl);
7578
}
7679

77-
// Insert the wrapper right before the hidden input
80+
// Insert the wrapper where the hidden input is, then nest the
81+
// hidden input inside so it stays grouped.
7882
hiddenInput.parentNode.insertBefore(wrapper, hiddenInput);
79-
// Move the hidden input inside the wrapper so it stays grouped
8083
wrapper.appendChild(hiddenInput);
81-
// Hide the original label (if any) since we created our own
8284
if (existingLabel) existingLabel.style.display = 'none';
8385

84-
// ── Drawing logic ──────────────────────────────────────────────────
86+
// ── Responsive canvas sizing ───────────────────────────────────────
8587
var ctx = canvas.getContext('2d');
88+
var dpr = window.devicePixelRatio || 1;
89+
90+
function sizeCanvas() {
91+
var containerWidth = canvasContainer.clientWidth || 300;
92+
// Set the backing-store size (physical pixels)
93+
canvas.width = containerWidth * dpr;
94+
canvas.height = CANVAS_CSS_HEIGHT * dpr;
95+
// Keep CSS size matched to container
96+
canvas.style.width = '100%';
97+
canvas.style.height = CANVAS_CSS_HEIGHT + 'px';
98+
// Scale context so drawing coordinates match CSS pixels
99+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
100+
}
101+
sizeCanvas();
102+
103+
// Re-size on window resize (debounced)
104+
var resizeTimer;
105+
window.addEventListener('resize', function () {
106+
clearTimeout(resizeTimer);
107+
resizeTimer = setTimeout(function () {
108+
// Preserve current drawing
109+
var imageData = canvas.toDataURL('image/png');
110+
sizeCanvas();
111+
if (hasStrokes) {
112+
var img = new Image();
113+
img.onload = function () {
114+
ctx.drawImage(img, 0, 0, canvasContainer.clientWidth, CANVAS_CSS_HEIGHT);
115+
};
116+
img.src = imageData;
117+
}
118+
}, 150);
119+
});
120+
121+
// ── Drawing logic ──────────────────────────────────────────────────
86122
var drawing = false;
87123
var hasStrokes = false;
88124

@@ -97,8 +133,8 @@
97133
clientY = e.clientY;
98134
}
99135
return {
100-
x: (clientX - rect.left) * (canvas.width / rect.width),
101-
y: (clientY - rect.top) * (canvas.height / rect.height)
136+
x: (clientX - rect.left) * (canvas.width / dpr / rect.width),
137+
y: (clientY - rect.top) * (canvas.height / dpr / rect.height)
102138
};
103139
}
104140

@@ -128,7 +164,6 @@
128164
e.preventDefault();
129165
drawing = false;
130166
ctx.closePath();
131-
// Write the data URI into the hidden input
132167
if (hasStrokes) {
133168
hiddenInput.value = canvas.toDataURL('image/png');
134169
}
@@ -147,7 +182,7 @@
147182

148183
// Clear button
149184
clearBtn.addEventListener('click', function () {
150-
ctx.clearRect(0, 0, canvas.width, canvas.height);
185+
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
151186
hiddenInput.value = '';
152187
hasStrokes = false;
153188
});

django_forms_workflows/templates/django_forms_workflows/approve.html

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,6 @@
55

66
{% block title %}Approve Submission #{{ submission.id }} - {{ site_name }}{% endblock %}
77

8-
{% block extra_css %}
9-
<style>
10-
.signature-pad-canvas-container {
11-
border: 1px solid #ced4da;
12-
border-radius: .375rem;
13-
background: #fff;
14-
display: inline-block;
15-
cursor: crosshair;
16-
touch-action: none;
17-
}
18-
.signature-pad-canvas {
19-
display: block;
20-
width: 100%;
21-
max-width: 500px;
22-
height: auto;
23-
}
24-
</style>
25-
{% endblock %}
26-
278
{% block content %}
289
<div class="row">
2910
<div class="col-lg-10 mx-auto">

django_forms_workflows/templates/django_forms_workflows/base.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{% load static %}
12
<!DOCTYPE html>
23
<html lang="en">
34
<head>
@@ -11,6 +12,9 @@
1112
<!-- Bootstrap Icons -->
1213
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
1314

15+
<!-- Form alignment, spacing & responsive polish -->
16+
<link rel="stylesheet" href="{% static 'django_forms_workflows/css/forms.css' %}">
17+
1418
<style>
1519
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
1620
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial, sans-serif; }

django_forms_workflows/templates/django_forms_workflows/form_submit.html

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -114,25 +114,6 @@ <h1>{{ form_def.name }}</h1>
114114
</div>
115115
{% endblock %}
116116

117-
{% block extra_css %}
118-
<style>
119-
.signature-pad-canvas-container {
120-
border: 1px solid #ced4da;
121-
border-radius: .375rem;
122-
background: #fff;
123-
display: inline-block;
124-
cursor: crosshair;
125-
touch-action: none;
126-
}
127-
.signature-pad-canvas {
128-
display: block;
129-
width: 100%;
130-
max-width: 500px;
131-
height: auto;
132-
}
133-
</style>
134-
{% endblock %}
135-
136117
{% block extra_js %}
137118
<script src="{% static 'django_forms_workflows/js/form-enhancements.js' %}"></script>
138119
<script src="{% static 'django_forms_workflows/js/signature-pad.js' %}"></script>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-forms-workflows"
3-
version = "0.40.0"
3+
version = "0.40.1"
44
description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration"
55
license = "LGPL-3.0-only"
66
readme = "README.md"

0 commit comments

Comments
 (0)