django-jsonsson 0.1.2__tar.gz

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,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ black:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v6
20
+ - uses: astral-sh/setup-uv@v6
21
+ - run: make lint
22
+
23
+ tests:
24
+ runs-on: ubuntu-latest
25
+ strategy:
26
+ matrix:
27
+ include:
28
+ - python: "3.10"
29
+ django: "4.2"
30
+ - python: "3.12"
31
+ django: "5.2"
32
+ - python: "3.14"
33
+ django: "6.0"
34
+ steps:
35
+ - uses: actions/checkout@v6
36
+ - uses: astral-sh/setup-uv@v6
37
+ with:
38
+ python-version: ${{ matrix.python }}
39
+ - run: uv run --extra dev --with "django~=${{ matrix.django }}.0" pytest
@@ -0,0 +1,74 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ env:
14
+ TAG: ${{ github.event.release.tag_name }}
15
+ steps:
16
+ # hatch-vcs reads the version from the git tag, so it needs the tags.
17
+ - name: Check out the tag
18
+ uses: actions/checkout@v6
19
+ with:
20
+ ref: ${{ env.TAG }}
21
+ fetch-depth: 0
22
+
23
+ - uses: astral-sh/setup-uv@v6
24
+
25
+ - name: Check the tag format
26
+ run: |
27
+ if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
28
+ echo "::error::Tag $TAG isn't in vX.Y.Z format"
29
+ exit 1
30
+ fi
31
+
32
+ - name: Lint
33
+ run: make lint
34
+
35
+ - name: Test
36
+ run: uv run --extra dev pytest
37
+
38
+ - name: Build
39
+ run: uv build
40
+
41
+ # A dirty tree or a wrong checkout gives a .devN version instead.
42
+ - name: Check the built version matches the tag
43
+ run: ls "dist/django_jsonsson-${TAG#v}-py3-none-any.whl" "dist/django_jsonsson-${TAG#v}.tar.gz"
44
+
45
+ - name: Check the wheel has templates and static files
46
+ run: |
47
+ files=$(unzip -l dist/*.whl)
48
+ grep -q "jsonsson/templates/jsonsson/widgets/list.html" <<< "$files"
49
+ grep -q "jsonsson/static/jsonsson/js/list.js" <<< "$files"
50
+ grep -q "jsonsson/static/jsonsson/css/list.css" <<< "$files"
51
+
52
+ - uses: actions/upload-artifact@v4
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+
57
+ # Separate job, so only the upload step gets the OIDC token.
58
+ # Uses PyPI trusted publishing, no API token is stored in the repo.
59
+ publish:
60
+ needs: build
61
+ runs-on: ubuntu-latest
62
+ environment:
63
+ name: pypi
64
+ url: https://pypi.org/p/django-jsonsson
65
+ permissions:
66
+ id-token: write
67
+ steps:
68
+ - uses: actions/download-artifact@v4
69
+ with:
70
+ name: dist
71
+ path: dist/
72
+
73
+ - name: Publish to PyPI
74
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,121 @@
1
+ # django-jsonsson - AI Agent Guide
2
+
3
+ Reusable Django form widgets that render `JSONField` values as plain, editable inputs. Install name `django-jsonsson`, import name `jsonsson`.
4
+
5
+ User-facing docs live in `README.md`. This file is for working on the library itself.
6
+
7
+ > **Self-update rule**: after finishing a task, ask "would a future agent get this wrong without knowing what I just learned?" If yes, update this file in the same changeset. Add gotchas, corrected assumptions, new build or release steps, new conventions. Don't add ticket-specific details or temporary workarounds. If the change is visible to users (new widget, new option, changed behavior), update `README.md` too.
8
+
9
+ ## Tech Stack
10
+
11
+ | Layer | Technology |
12
+ |-------|-----------|
13
+ | Language | Python 3.10+ |
14
+ | Framework | Django 4.2+ (CI: 4.2, 5.2, 6.0) |
15
+ | Frontend | Plain ES5-style JS and CSS, no build step, no dependencies |
16
+ | Build backend | `hatchling` (src layout) |
17
+ | Package manager | `uv` |
18
+ | Tests | `pytest` + `pytest-django` |
19
+ | Formatter | `black` (enforced in CI) |
20
+
21
+ ## Layout
22
+
23
+ ```
24
+ src/jsonsson/
25
+ __init__.py # public API (re-exports widgets), __version__ from package metadata
26
+ apps.py # JsonssonConfig, only so templates/static are found
27
+ widgets.py # ListWidget
28
+ templates/jsonsson/widgets/list.html # ListWidget markup
29
+ static/jsonsson/js/list.js # builds inputs, syncs JSON back to the textarea
30
+ static/jsonsson/css/list.css # layout only
31
+ tests/
32
+ settings.py # Django settings for pytest (admin, sessions, messages)
33
+ urls.py # admin URLs, used by test_admin.py
34
+ testapp/ # sample ports editor in an admin inline (models + admin)
35
+ test_list_widget.py
36
+ test_admin.py # ListWidget through the real admin
37
+ .github/workflows/ci.yaml # black + pytest matrix
38
+ ```
39
+
40
+ ## How a widget works
41
+
42
+ Every widget follows the same pattern. Keep it for new widgets.
43
+
44
+ 1. **Python** (`widgets.py`): a `forms.Textarea` subclass. `get_context()` adds the `jsonsson-<widget>-source` class and `display:none` to the textarea, then puts the widget's options under `context["widget"]["jsonsson"]`.
45
+ 2. **Template** (`templates/jsonsson/widgets/<widget>.html`): an outer container with the options as `data-*` attributes, the stock `django/forms/widgets/textarea.html` included inside it, plus any static controls (the add button).
46
+ 3. **JS** (`static/jsonsson/js/<widget>.js`): finds containers, reads `data-*`, parses the textarea's JSON, builds inputs, and writes `JSON.stringify(...)` back into the textarea on every change.
47
+ 4. **Backend**: Django only ever sees the textarea. The browser never coerces types. Converting and validating is the host form's `clean_<field>()`.
48
+
49
+ The Python -> template -> JS contract for `ListWidget`:
50
+
51
+ | Python (`widget.jsonsson`) | HTML | JS reads |
52
+ |---------------------------|------|----------|
53
+ | `block` | `jsonsson-list--block` class | (CSS only) |
54
+ | `min_items` | `data-min-items` | `editor.dataset.minItems` |
55
+ | `remove_label` | `data-remove-label` | `editor.dataset.removeLabel` |
56
+ | `input_attrs` (JSON string) | `data-input-attrs` | `JSON.parse(editor.dataset.inputAttrs)` |
57
+ | `add_label`, `add_text` | add button `title`/`aria-label`/text | (static markup) |
58
+
59
+ Changing any name means changing all three places, plus the README tables.
60
+
61
+ ## Adding a new widget
62
+
63
+ 1. Add the class to `widgets.py`, and export it from `__init__.py` (`__all__` too).
64
+ 2. Add `templates/jsonsson/widgets/<name>.html`, `static/jsonsson/js/<name>.js`, `static/jsonsson/css/<name>.css`, and declare the assets in the widget's `Media`.
65
+ 3. Prefix every CSS class and data attribute with `jsonsson-<name>`. Wrap the JS in an IIFE and use a namespaced init flag (`dataset.jsonssonInit`-style) so it's idempotent.
66
+ 4. Handle `DOMContentLoaded` and `formset:added`, and skip elements whose source `name` contains `__prefix__` (the admin's empty formset template row).
67
+ 5. Add tests (see below) and document it in `README.md` (widgets table, options, CSS classes, template context).
68
+
69
+ ## Conventions
70
+
71
+ - **Namespacing**: all CSS classes, data attributes, template paths and static paths start with `jsonsson`. This is a library that lands in someone else's project, so nothing generic like `.list-editor` or `admin/js/...`.
72
+ - **Markup in templates, not Python strings**: users override `jsonsson/widgets/*.html`. Escaping comes from template autoescape. Don't go back to `mark_safe(f"...")`.
73
+ - **CSS is layout only**: spacing and show/hide. Sizing and colors belong to the host project, which adds its own CSS via a subclass `Media` (Django merges it with ours).
74
+ - **Inputs never restrict typing**: generated inputs are always plain `<input type="text">`. `mode`/`value_type` are backend hints only and must never change rendering. Restrictions go through `input_attrs`, which the user sets on purpose.
75
+ - **Submitted values are always strings**: trimmed, empty ones dropped. Don't add type coercion in JS.
76
+ - **Option resolution order** (`ListWidget.__init__`): constructor kwarg, then class attribute, then the `MODES` preset, then the hardcoded default. `value_type` and `input_attrs` default to `None` on the class precisely so this fallback works. Keep it when adding options.
77
+ - **Mutable defaults**: `input_attrs` must end up a fresh dict per instance, including when it comes from a class attribute. `__init__` copies it, and `__deepcopy__` copies it again because Django deep-copies widgets per form (`Meta.widgets`) and `Widget.__deepcopy__` only copies `attrs`.
78
+ - **Public API** is whatever `jsonsson/__init__.py` exports, plus the template context keys and CSS class names documented in the README. Treat changes to any of them as breaking.
79
+
80
+ ## Gotchas
81
+
82
+ - **Widget `attrs` merge**: `get_context()` merges `self.attrs` with the passed `attrs` *before* appending our class and style. Django's own `build_attrs()` lets passed attrs replace widget attrs, so without this merge a `class`/`style` given to the constructor is silently dropped. Covered by `test_render_keeps_caller_class_and_style`.
83
+ - **`input_attrs` are DOM properties**: `list.js` does `input[key] = value`, not `setAttribute()`. So keys must be JS property names (`maxLength`, `readOnly`). `maxlength` silently does nothing.
84
+ - **`null` value breaks the JS**: `forms.JSONField.prepare_value(None)` renders `null`, `JSON.parse` returns `null`, and `initEditor()` throws on `values.length`. Same for a JSON object. Documented as a README limitation. If you fix it, update the README too.
85
+ - **Admin icon path**: `list.css` points at `url(../../admin/img/icon-addlink.svg)`, relative to `static/jsonsson/css/`, so it resolves to `STATIC_URL/admin/img/...`. It only exists when `django.contrib.admin` is installed. `--border-color` (an admin CSS variable) has a `#ccc` fallback. Don't make the library depend on the admin beyond that.
86
+ - **Template lookup**: the widget template is found through the form renderer's app-dirs lookup, which is why `jsonsson` has to be in `INSTALLED_APPS`. Tests rely on that too (`tests/settings.py`).
87
+ - **`uv` picking the wrong venv**: if the shell has `VIRTUAL_ENV` set from another project, `uv` warns and ignores it. Run `unset VIRTUAL_ENV` if anything looks odd.
88
+
89
+ ## Testing
90
+
91
+ ```bash
92
+ make test # uv sync --extra dev + pytest
93
+ make lint # black --check
94
+ make fmt # black
95
+ ```
96
+
97
+ - Tests are pure Python: options and fallbacks, rendered HTML (classes, `data-*`, escaping, attrs merging), form round trip through `forms.JSONField`, `Media`, and that static files are found.
98
+ - **Admin integration**: `tests/testapp/admin.py` is a typical setup, a `PortsListWidget` on a `Service.ports` JSONField in an admin `TabularInline`. `tests/test_admin.py` covers it through the real admin: class-attribute options, subclass `Media` merging (host CSS after ours), `Meta.widgets` copies, rendering existing int lists and the `__prefix__` row, and posting string lists that `clean_ports()` turns into ints. If one of these fails, the change will likely break real projects. Don't loosen the test, either fix the change or treat it as breaking.
99
+ - There are **no JS tests**. Any change to `list.js` or `list.css` needs a manual browser check: add, remove, `min_items` hiding the remove buttons, the hidden textarea's value after each change, and a row added through an admin inline formset.
100
+ - Assert on the exact rendered markup or behavior, not just "renders without error". A wrong template branch still renders fine.
101
+ - To check an older Django version locally, like CI does:
102
+
103
+ ```bash
104
+ uv run --isolated --extra dev --python 3.12 --with "django~=4.2.0" pytest
105
+ ```
106
+
107
+ ## Release
108
+
109
+ Releases go to PyPI as `django-jsonsson`. The license is MIT (`LICENSE`, declared via `license`/`license-files` in `pyproject.toml`, which needs `hatchling>=1.27`).
110
+
111
+ The version is never written in the code. `hatch-vcs` takes it from the git tag at build time, and `__version__` reads it back from the installed package metadata. Between tags, builds get a `.devN` version (e.g. `0.1.2.dev3+g1a2b3c4`), which is expected.
112
+
113
+ 1. Merge everything to `main`.
114
+ 2. Publish a GitHub release tagged `v<version>` (e.g. `v0.2.0`) from `main`.
115
+ 3. `.github/workflows/release.yaml` runs on `release: published`. It checks out the tag with full history (hatch-vcs needs the tags), fails if the tag isn't `vX.Y.Z`, runs lint and tests, builds with `uv build`, checks the built files have exactly the tag's version and the wheel has `templates/` and `static/`, then a separate `publish` job uploads the wheel and sdist to PyPI.
116
+
117
+ Publishing uses PyPI trusted publishing (OIDC), so there's no API token secret. It needs a trusted publisher on PyPI for this repo, workflow `release.yaml` and environment `pypi`, plus a `pypi` environment in the GitHub repo settings. If the repo, workflow file or environment is renamed, update the PyPI side too or the upload fails. A version can only be uploaded to PyPI once, so a failed release after upload needs a version bump, not a re-run.
118
+
119
+ A release created by another workflow with `GITHUB_TOKEN` won't trigger it (GitHub doesn't fire events for those). If you add such a workflow, call `release.yaml` through `workflow_call`.
120
+
121
+ Consumers install it with `pip install django-jsonsson` and pin versions as usual.
@@ -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.
@@ -0,0 +1,21 @@
1
+ .PHONY: help install-dev test fmt lint build
2
+
3
+ .DEFAULT_GOAL := help
4
+
5
+ help: ## Show this help message
6
+ @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
7
+
8
+ install-dev: ## Install all dependencies (including dev)
9
+ uv sync --extra dev
10
+
11
+ test: install-dev ## Run tests
12
+ uv run pytest
13
+
14
+ fmt: ## Format code with black
15
+ uv run --extra dev black src tests
16
+
17
+ lint: ## Check formatting (CI)
18
+ uv run --extra dev black --check src tests
19
+
20
+ build: ## Build sdist and wheel into dist/
21
+ uv build
@@ -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.