django-jsonsson 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,281 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-jsonsson
3
+ Version: 0.1.2
4
+ Summary: Django form widgets that turn JSONField values into plain, editable inputs.
5
+ Author: Pavlo K.
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Framework :: Django
9
+ Classifier: Framework :: Django :: 4.2
10
+ Classifier: Framework :: Django :: 5.2
11
+ Classifier: Framework :: Django :: 6.0
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: django>=4.2
15
+ Provides-Extra: dev
16
+ Requires-Dist: black; extra == 'dev'
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Requires-Dist: pytest-django; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Jsonsson
22
+
23
+ Django form widgets that show a `JSONField` value as plain, editable inputs instead of a raw JSON textarea.
24
+
25
+ Nobody likes typing `["80", "443"]` by hand into a textarea. With Jsonsson the user gets one input per value, a button to add more and a cross to remove one. The form still submits normal JSON, so your model, form field and validation don't change.
26
+
27
+ ## How it works
28
+
29
+ The real form field is still there, rendered as a hidden `<textarea>` with the JSON value inside. A small script reads that JSON, builds the inputs from it, and writes the JSON back into the textarea on every change. Django never sees the generated inputs, only the textarea.
30
+
31
+ This means:
32
+
33
+ - Works with Django's own `forms.JSONField` and `models.JSONField`, no custom field needed.
34
+ - Validation and type conversion stay on the backend, in your form's `clean_<field>()`.
35
+ - No build step and no frontend dependencies. Plain JS and CSS.
36
+
37
+ ## Widgets
38
+
39
+ | Widget | JSON shape | Example value |
40
+ |--------|------------|---------------|
41
+ | `ListWidget` | Flat list of scalar values | `["80", "443"]` |
42
+
43
+ More JSON shapes (objects, single values) are planned.
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.10+
48
+ - Django 4.2+
49
+ - `django.contrib.admin` is optional, but the bundled CSS uses its add icon and colors (see [Styling](#styling)).
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install django-jsonsson
55
+ # or
56
+ uv add django-jsonsson
57
+ ```
58
+
59
+ Add the app to `INSTALLED_APPS`, so Django can find its templates and static files:
60
+
61
+ ```python
62
+ INSTALLED_APPS = [
63
+ # ...
64
+ "jsonsson",
65
+ ]
66
+ ```
67
+
68
+ The widget's JS and CSS are declared in its `Media`. The Django admin includes them automatically. In your own templates, render `{{ form.media }}` like for any other widget with assets.
69
+
70
+ ## ListWidget
71
+
72
+ ### Quick start
73
+
74
+ A model with a list field:
75
+
76
+ ```python
77
+ from django.db import models
78
+
79
+
80
+ class Service(models.Model):
81
+ name = models.CharField(max_length=100)
82
+ tags = models.JSONField(default=list, blank=True)
83
+ ```
84
+
85
+ Use the widget in a form:
86
+
87
+ ```python
88
+ from django import forms
89
+ from jsonsson import ListWidget
90
+
91
+
92
+ class ServiceForm(forms.ModelForm):
93
+ class Meta:
94
+ model = Service
95
+ fields = "__all__"
96
+ widgets = {"tags": ListWidget()}
97
+ ```
98
+
99
+ Use that form in the admin:
100
+
101
+ ```python
102
+ from django.contrib import admin
103
+
104
+
105
+ @admin.register(Service)
106
+ class ServiceAdmin(admin.ModelAdmin):
107
+ form = ServiceForm
108
+ ```
109
+
110
+ Or use the widget for every `JSONField` in a model admin:
111
+
112
+ ```python
113
+ from django.db import models
114
+
115
+
116
+ class ServiceAdmin(admin.ModelAdmin):
117
+ formfield_overrides = {models.JSONField: {"widget": ListWidget}}
118
+ ```
119
+
120
+ Each value gets its own `<input type="text">` with a remove button, and there's an "Add item" button at the end.
121
+
122
+ ### Options
123
+
124
+ Set them as constructor kwargs, or as class attributes on a subclass. Kwargs win over class attributes.
125
+
126
+ | Option | Default | What it does |
127
+ |--------|---------|--------------|
128
+ | `mode` | `"text"` | Shortcut for `value_type`. Built in: `"text"` (string) and `"digits"` (number). |
129
+ | `value_type` | from `mode` | `"string"` or `"number"`. A hint for your backend code only, read it as `widget.value_type`. It doesn't change rendering or what the browser submits. |
130
+ | `input_attrs` | `{}` | Extra attributes set on every value input, e.g. `{"maxLength": 5, "placeholder": "Port"}`. |
131
+ | `min_items` | `0` | How many inputs are always on screen. Remove buttons hide when this count is reached. |
132
+ | `layout` | `"block"` | `"block"`: one item per row. `"inline"`: items wrap left to right, good for a table cell. |
133
+ | `add_label` | `"Add item"` | Accessible name (`title` and `aria-label`) of the add button. |
134
+ | `add_text` | `"Add item"` | Visible text of the add button. `""` shows only the icon. |
135
+ | `remove_label` | `"Remove item"` | Accessible name of each remove button. |
136
+
137
+ Standard widget `attrs` work too. A `class` or `style` you pass is added to the hidden textarea next to the widget's own.
138
+
139
+ `input_attrs` keys are set as DOM properties, not HTML attributes. Use the JS names: `maxLength`, `readOnly`, `inputMode`, not `maxlength`, `readonly`, `inputmode`.
140
+
141
+ ### Reusable subclasses
142
+
143
+ When the same setup is used in several places, put it in a subclass:
144
+
145
+ ```python
146
+ class PortsWidget(ListWidget):
147
+ mode = "digits"
148
+ layout = "inline"
149
+ min_items = 1
150
+ input_attrs = {"maxLength": 5}
151
+ add_label = "Add port"
152
+ add_text = ""
153
+ remove_label = "Remove port"
154
+ ```
155
+
156
+ ### Values are always strings
157
+
158
+ Whatever the mode, the browser submits a JSON array of strings. Each value is trimmed and empty inputs are dropped. So `[" 80", "", "443 "]` on screen becomes `["80", "443"]`.
159
+
160
+ Converting and checking values is your form's job. `mode` and `value_type` only tell your code what to expect:
161
+
162
+ ```python
163
+ class ServiceForm(forms.ModelForm):
164
+ class Meta:
165
+ model = Service
166
+ fields = "__all__"
167
+ widgets = {"ports": PortsWidget()}
168
+
169
+ def clean_ports(self):
170
+ cleaned = []
171
+ for item in self.cleaned_data.get("ports") or []:
172
+ try:
173
+ port = int(item)
174
+ except (TypeError, ValueError):
175
+ raise forms.ValidationError(f"'{item}' is not a valid port number.")
176
+ if not 1 <= port <= 65535:
177
+ raise forms.ValidationError(f"Port {port} must be between 1 and 65535.")
178
+ cleaned.append(port)
179
+ return cleaned
180
+ ```
181
+
182
+ The inputs never limit what the user can type. If you want a limit in the browser, add it yourself through `input_attrs`.
183
+
184
+ ### Custom modes
185
+
186
+ Add a mode with a `MODES` dict on a subclass:
187
+
188
+ ```python
189
+ class MyListWidget(ListWidget):
190
+ MODES = {
191
+ **ListWidget.MODES,
192
+ "decimal": {"value_type": "decimal"},
193
+ }
194
+
195
+
196
+ MyListWidget(mode="decimal").value_type # "decimal"
197
+ ```
198
+
199
+ An unknown mode falls back to `value_type="string"`.
200
+
201
+ ### Inline formsets
202
+
203
+ Widgets in rows added with the admin's "Add another" button work too. The script listens for Django's `formset:added` event and skips the hidden `__prefix__` template row.
204
+
205
+ If you add rows with your own JS, dispatch the same event from the new row, so the widget gets set up:
206
+
207
+ ```js
208
+ row.dispatchEvent(new CustomEvent("formset:added", { bubbles: true }));
209
+ ```
210
+
211
+ ## Styling
212
+
213
+ The bundled CSS (`jsonsson/css/list.css`) only does layout: spacing, the remove cross inside the input, hiding remove buttons at `min_items`. It looks native in the Django admin because it uses the admin's `admin/img/icon-addlink.svg` icon and `--border-color` variable. Outside the admin it still works. The add button just has no icon, and the hover color falls back to `#ccc`.
214
+
215
+ Sizing is up to you. Add your own CSS through the widget's `Media`:
216
+
217
+ ```python
218
+ class PortsWidget(ListWidget):
219
+ class Media:
220
+ css = {"all": ("myapp/css/ports.css",)}
221
+ ```
222
+
223
+ ```css
224
+ .jsonsson-list .jsonsson-list-value {
225
+ width: 5ch;
226
+ box-sizing: content-box;
227
+ }
228
+ ```
229
+
230
+ Django merges a subclass's `Media` with its parent's, so the bundled JS and CSS are still included.
231
+
232
+ ### CSS classes
233
+
234
+ | Class | Element |
235
+ |-------|---------|
236
+ | `jsonsson-list` | Outer container |
237
+ | `jsonsson-list--block` | Outer container, when `layout="block"` |
238
+ | `jsonsson-list-source` | The hidden textarea with the real value |
239
+ | `jsonsson-list-items` | Wrapper around items and the add button |
240
+ | `jsonsson-list-items--at-min` | Same wrapper, when the item count is at `min_items` |
241
+ | `jsonsson-list-item` | One item (input plus remove button) |
242
+ | `jsonsson-list-value` | The input of one item |
243
+ | `jsonsson-list-remove` | Remove button of one item |
244
+ | `jsonsson-list-add` | Add button |
245
+ | `jsonsson-list-add--icon-only` | Add button, when `add_text=""` |
246
+
247
+ ### Custom markup
248
+
249
+ The HTML comes from the `jsonsson/widgets/list.html` template. Override it in your project's templates to change it. Keep the CSS classes and `data-*` attributes, the script depends on them.
250
+
251
+ With Django's default form renderer, templates are looked up in installed apps, so put the override in an app listed before `jsonsson` in `INSTALLED_APPS`. To use project-level `TEMPLATES` dirs, set `FORM_RENDERER = "django.forms.renderers.TemplatesSetting"`.
252
+
253
+ The template gets the usual widget context, plus `widget.jsonsson`:
254
+
255
+ | Key | Value |
256
+ |-----|-------|
257
+ | `block` | `True` when `layout="block"` |
258
+ | `min_items` | `min_items` as an int |
259
+ | `input_attrs` | `input_attrs` as a JSON string |
260
+ | `add_label`, `add_text`, `remove_label` | The labels |
261
+
262
+ ## Limitations
263
+
264
+ - The field value must be a JSON array. `null` or an object breaks the script. For a model field, use `default=list`, and avoid `null=True` or make sure the value is never `None`.
265
+ - Only flat lists of scalar values. Nested lists and objects aren't supported yet.
266
+ - Values come back as strings (see above).
267
+ - Needs JavaScript. Without it the user sees nothing, since the textarea is hidden.
268
+
269
+ ## Development
270
+
271
+ Uses [uv](https://docs.astral.sh/uv/).
272
+
273
+ ```bash
274
+ make install-dev # install all dependencies
275
+ make test # run tests
276
+ make fmt # format with black
277
+ make lint # check formatting (CI)
278
+ make build # build sdist and wheel into dist/
279
+ ```
280
+
281
+ CI runs `black --check` and the tests on Python 3.10 / Django 4.2, Python 3.12 / Django 5.2 and Python 3.14 / Django 6.0.
@@ -0,0 +1,10 @@
1
+ jsonsson/__init__.py,sha256=LN7mD4ONoa0_k-xAIU5KaDwBHLI0edMq1QNPnwjJwQ0,147
2
+ jsonsson/apps.py,sha256=k9tLwpcJ9hX84sB5PlW45x2-as2S_8W9GQtSek32LeM,121
3
+ jsonsson/widgets.py,sha256=Bgneu-gEb2YQykMHFu5Z-PAy0oDCekBUDLEqO3ua7Yo,5364
4
+ jsonsson/static/jsonsson/css/list.css,sha256=B1qLuVkOeErX6uV3pKNAQJ3Q_nkFANgZ-P0pOiCFhBY,1586
5
+ jsonsson/static/jsonsson/js/list.js,sha256=zxh3nS597BWVc9H1BNXwNpOwN4tlq8yu-3hpJiyPegI,3530
6
+ jsonsson/templates/jsonsson/widgets/list.html,sha256=Pwr3B3IKyZ_nGCJGXglfVhfLLDFtkodmZ2a1EifgLsg,576
7
+ django_jsonsson-0.1.2.dist-info/METADATA,sha256=0ALPrW_3vcNMI0qd8T76eVKDa9P0SqmONgcsNuye5rI,9786
8
+ django_jsonsson-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ django_jsonsson-0.1.2.dist-info/licenses/LICENSE,sha256=RdOWX56y2PtVAYbo8MHn5yzZMWjEDb3DowUxFIjKOr4,1065
10
+ django_jsonsson-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pavlo K.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jsonsson/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version
2
+
3
+ from jsonsson.widgets import ListWidget
4
+
5
+ __all__ = ["ListWidget"]
6
+ __version__ = version("django-jsonsson")
jsonsson/apps.py ADDED
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class JsonssonConfig(AppConfig):
5
+ name = "jsonsson"
6
+ verbose_name = "Jsonsson"
@@ -0,0 +1,70 @@
1
+ .jsonsson-list .jsonsson-list-items {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: 6px;
5
+ }
6
+
7
+ .jsonsson-list--block .jsonsson-list-items {
8
+ flex-direction: column;
9
+ align-items: flex-start;
10
+ }
11
+
12
+ .jsonsson-list .jsonsson-list-item {
13
+ position: relative;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ }
17
+
18
+ .jsonsson-list .jsonsson-list-value {
19
+ padding-right: 20px;
20
+ }
21
+
22
+ .jsonsson-list .jsonsson-list-remove {
23
+ position: absolute;
24
+ top: 45%;
25
+ right: 4px;
26
+ transform: translateY(-45%);
27
+ display: inline-flex;
28
+ align-items: center;
29
+ justify-content: center;
30
+ width: 14px;
31
+ height: 14px;
32
+ line-height: 1;
33
+ opacity: 0.6;
34
+ text-decoration: none !important;
35
+ }
36
+
37
+ .jsonsson-list .jsonsson-list-remove:hover,
38
+ .jsonsson-list .jsonsson-list-remove:focus {
39
+ opacity: 1;
40
+ text-decoration: none !important;
41
+ }
42
+
43
+ .jsonsson-list .jsonsson-list-items--at-min .jsonsson-list-remove {
44
+ display: none;
45
+ }
46
+
47
+ .jsonsson-list .jsonsson-list-add {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ height: 27px;
51
+ padding: 0 10px 0 22px;
52
+ border-radius: 3px;
53
+ background: url(../../admin/img/icon-addlink.svg) no-repeat 6px center / 13px 13px;
54
+ text-decoration: none !important;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .jsonsson-list .jsonsson-list-add--icon-only {
59
+ justify-content: center;
60
+ width: 20px;
61
+ min-width: 20px;
62
+ padding: 0;
63
+ background-position: center;
64
+ }
65
+
66
+ .jsonsson-list .jsonsson-list-add:hover,
67
+ .jsonsson-list .jsonsson-list-add:focus {
68
+ text-decoration: none !important;
69
+ background-color: var(--border-color, #ccc);
70
+ }
@@ -0,0 +1,117 @@
1
+ (function () {
2
+ function getMinItems(editor) {
3
+ var n = parseInt(editor.dataset.minItems, 10);
4
+ return isNaN(n) ? 1 : Math.max(0, n);
5
+ }
6
+
7
+ function getInputAttrs(editor) {
8
+ return JSON.parse(editor.dataset.inputAttrs || "{}");
9
+ }
10
+
11
+ function syncToSource(editor) {
12
+ // Always plain trimmed strings — type coercion (e.g. to numbers) and
13
+ // validation are the backend's job, not this widget's.
14
+ var source = editor.querySelector(".jsonsson-list-source");
15
+ var values = Array.prototype.map
16
+ .call(editor.querySelectorAll(".jsonsson-list-value"), function (input) {
17
+ return input.value.trim();
18
+ })
19
+ .filter(function (v) {
20
+ return v !== "";
21
+ });
22
+ source.value = JSON.stringify(values);
23
+ }
24
+
25
+ function updateRemoveVisibility(editor) {
26
+ var items = editor.querySelector(".jsonsson-list-items");
27
+ var count = items.querySelectorAll(".jsonsson-list-item").length;
28
+ items.classList.toggle(
29
+ "jsonsson-list-items--at-min",
30
+ count <= getMinItems(editor)
31
+ );
32
+ }
33
+
34
+ function makeItem(editor, value) {
35
+ var item = document.createElement("span");
36
+ item.className = "jsonsson-list-item";
37
+
38
+ var input = document.createElement("input");
39
+ input.type = "text";
40
+ input.className = "jsonsson-list-value";
41
+ var inputAttrs = getInputAttrs(editor);
42
+ Object.keys(inputAttrs).forEach(function (key) {
43
+ input[key] = inputAttrs[key];
44
+ });
45
+ input.value = value === undefined || value === null ? "" : value;
46
+
47
+ input.addEventListener("input", function () {
48
+ syncToSource(editor);
49
+ });
50
+
51
+ var removeLabel = editor.dataset.removeLabel || "Remove item";
52
+ var remove = document.createElement("a");
53
+ remove.href = "#";
54
+ remove.className = "jsonsson-list-remove";
55
+ remove.textContent = "×";
56
+ remove.title = removeLabel;
57
+ remove.setAttribute("aria-label", removeLabel);
58
+ remove.addEventListener("click", function (e) {
59
+ e.preventDefault();
60
+ if (
61
+ editor.querySelectorAll(".jsonsson-list-item").length <=
62
+ getMinItems(editor)
63
+ )
64
+ return;
65
+ item.remove();
66
+ updateRemoveVisibility(editor);
67
+ syncToSource(editor);
68
+ });
69
+
70
+ item.appendChild(input);
71
+ item.appendChild(remove);
72
+ return item;
73
+ }
74
+
75
+ function initEditor(editor) {
76
+ if (editor.dataset.jsonssonInit) return;
77
+ var src = editor.querySelector(".jsonsson-list-source");
78
+ if (!src || src.name.indexOf("__prefix__") !== -1) return;
79
+ editor.dataset.jsonssonInit = "1";
80
+
81
+ var items = editor.querySelector(".jsonsson-list-items");
82
+ var add = editor.querySelector(".jsonsson-list-add");
83
+ var minItems = getMinItems(editor);
84
+
85
+ var values = JSON.parse(src.value || "[]");
86
+ while (values.length < minItems) {
87
+ values.push("");
88
+ }
89
+ values.forEach(function (value) {
90
+ items.insertBefore(makeItem(editor, value), add);
91
+ });
92
+
93
+ add.addEventListener("click", function (e) {
94
+ e.preventDefault();
95
+ var item = makeItem(editor, "");
96
+ items.insertBefore(item, add);
97
+ item.querySelector(".jsonsson-list-value").focus();
98
+ updateRemoveVisibility(editor);
99
+ syncToSource(editor);
100
+ });
101
+
102
+ updateRemoveVisibility(editor);
103
+ syncToSource(editor);
104
+ }
105
+
106
+ function initAll(root) {
107
+ root.querySelectorAll(".jsonsson-list").forEach(initEditor);
108
+ }
109
+
110
+ document.addEventListener("DOMContentLoaded", function () {
111
+ initAll(document);
112
+ });
113
+
114
+ document.addEventListener("formset:added", function (event) {
115
+ initAll(event.target);
116
+ });
117
+ })();
@@ -0,0 +1 @@
1
+ <div class="jsonsson-list{% if widget.jsonsson.block %} jsonsson-list--block{% endif %}" data-min-items="{{ widget.jsonsson.min_items }}" data-remove-label="{{ widget.jsonsson.remove_label }}" data-input-attrs="{{ widget.jsonsson.input_attrs }}">{% include "django/forms/widgets/textarea.html" %}<div class="jsonsson-list-items"><a href="#" class="jsonsson-list-add{% if not widget.jsonsson.add_text %} jsonsson-list-add--icon-only{% endif %}" title="{{ widget.jsonsson.add_label }}" aria-label="{{ widget.jsonsson.add_label }}">{{ widget.jsonsson.add_text }}</a></div></div>
jsonsson/widgets.py ADDED
@@ -0,0 +1,138 @@
1
+ import json
2
+
3
+ from django import forms
4
+
5
+
6
+ class ListWidget(forms.Textarea):
7
+ """Compact editor for a JSONField holding a flat list of scalar values.
8
+
9
+ Renders the real field as a hidden <textarea> kept in sync (as a JSON
10
+ array) with small add/remove inputs - see jsonsson/js/list.js and
11
+ jsonsson/css/list.css. This widget only ships layout-neutral CSS needed
12
+ to look native in the Django admin. Sizing and other visual polish
13
+ belong in the caller's own stylesheet, loaded alongside a subclass or
14
+ form.
15
+
16
+ Every generated value input is always a plain, unrestricted
17
+ <input type="text">. Nothing here ever changes what a user can type,
18
+ on-screen appearance, or keyboard. Configure per-use behaviour by
19
+ subclassing or passing constructor kwargs:
20
+
21
+ - mode / value_type: purely a backend concern, with zero effect on
22
+ rendering. "string" (default) or "number" - describes what a
23
+ caller's own clean_<field>() should coerce/validate each submitted
24
+ entry into (e.g. int() plus a range check). Read it back via
25
+ `widget.value_type`. The widget doesn't use it itself, and the
26
+ submitted JSON array is always plain strings regardless of mode -
27
+ type coercion and validation are entirely the backend's job. `mode`
28
+ is just a named shortcut for `value_type`. Built in: "text"
29
+ (value_type="string") and "digits" (value_type="number"). Add more
30
+ via a `MODES` dict on a subclass, or skip mode entirely and pass
31
+ value_type directly.
32
+ - input_attrs: extra HTML attributes applied to every generated value
33
+ <input> (e.g. {"maxLength": 5}), entirely independent of mode. Keys
34
+ are set as DOM properties, so use their JS names (maxLength, not
35
+ maxlength).
36
+ - min_items: lowest number of inputs kept on screen, 0 or more
37
+ (default 0).
38
+ - layout: "block" (default, one item per row) or "inline" (items wrap
39
+ left-to-right, handy for a compact table cell).
40
+ - add_label / remove_label: the accessible name (title/aria-label) for
41
+ the add/remove controls. Always present for screen readers,
42
+ regardless of add_text below.
43
+ - add_text: the visible text shown next to the add button's icon,
44
+ defaulting to "Add item". Pass "" to show just the icon (e.g. for a
45
+ cramped inline cell). add_label still provides the accessible name
46
+ either way.
47
+ """
48
+
49
+ template_name = "jsonsson/widgets/list.html"
50
+
51
+ MODES = {
52
+ "text": {"value_type": "string"},
53
+ "digits": {"value_type": "number"},
54
+ }
55
+
56
+ mode = "text"
57
+ min_items = 0
58
+ layout = "block"
59
+ add_label = "Add item"
60
+ add_text = "Add item"
61
+ remove_label = "Remove item"
62
+
63
+ # Left unset (None) so __init__ can fall back to the active mode's
64
+ # preset. Set explicitly on a subclass, or pass as a kwarg, to
65
+ # override without picking a whole different mode.
66
+ value_type = None
67
+ input_attrs = None
68
+
69
+ class Media:
70
+ js = ("jsonsson/js/list.js",)
71
+ css = {"all": ("jsonsson/css/list.css",)}
72
+
73
+ def __init__(
74
+ self,
75
+ *args,
76
+ mode=None,
77
+ min_items=None,
78
+ layout=None,
79
+ value_type=None,
80
+ input_attrs=None,
81
+ add_label=None,
82
+ add_text=None,
83
+ remove_label=None,
84
+ **kwargs,
85
+ ):
86
+ super().__init__(*args, **kwargs)
87
+ if mode is not None:
88
+ self.mode = mode
89
+ if min_items is not None:
90
+ self.min_items = min_items
91
+ if layout is not None:
92
+ self.layout = layout
93
+ if add_label is not None:
94
+ self.add_label = add_label
95
+ if add_text is not None:
96
+ self.add_text = add_text
97
+ if remove_label is not None:
98
+ self.remove_label = remove_label
99
+
100
+ preset = self.MODES.get(self.mode, {})
101
+ self.value_type = (
102
+ value_type
103
+ if value_type is not None
104
+ else (
105
+ self.value_type
106
+ if self.value_type is not None
107
+ else preset.get("value_type", "string")
108
+ )
109
+ )
110
+ # Copied so instances never share one dict, even when it comes from
111
+ # a class attribute on a subclass.
112
+ self.input_attrs = dict(
113
+ input_attrs if input_attrs is not None else (self.input_attrs or {})
114
+ )
115
+
116
+ def __deepcopy__(self, memo):
117
+ # Django deep-copies widgets for each form, but Widget.__deepcopy__
118
+ # only copies `attrs`.
119
+ obj = super().__deepcopy__(memo)
120
+ obj.input_attrs = self.input_attrs.copy()
121
+ return obj
122
+
123
+ def get_context(self, name, value, attrs):
124
+ # Merge the widget's own attrs first, so a class/style given to the
125
+ # constructor is combined with ours instead of replaced by it.
126
+ attrs = {**self.attrs, **(attrs or {})}
127
+ attrs["class"] = (attrs.get("class", "") + " jsonsson-list-source").strip()
128
+ attrs["style"] = "display:none;" + attrs.get("style", "")
129
+ context = super().get_context(name, value, attrs)
130
+ context["widget"]["jsonsson"] = {
131
+ "block": self.layout == "block",
132
+ "min_items": int(self.min_items),
133
+ "input_attrs": json.dumps(self.input_attrs),
134
+ "add_label": self.add_label,
135
+ "add_text": self.add_text,
136
+ "remove_label": self.remove_label,
137
+ }
138
+ return context