polyadmin 0.1.0b1__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.
- polyadmin/__init__.py +47 -0
- polyadmin/core/__init__.py +0 -0
- polyadmin/core/_async.py +21 -0
- polyadmin/core/action.py +135 -0
- polyadmin/core/admin.py +129 -0
- polyadmin/core/audit.py +65 -0
- polyadmin/core/auth.py +52 -0
- polyadmin/core/authorization.py +48 -0
- polyadmin/core/csrf.py +70 -0
- polyadmin/core/dashboard.py +41 -0
- polyadmin/core/delete.py +146 -0
- polyadmin/core/exporter.py +136 -0
- polyadmin/core/field.py +201 -0
- polyadmin/core/filter.py +305 -0
- polyadmin/core/inline.py +97 -0
- polyadmin/core/login.py +110 -0
- polyadmin/core/model_admin.py +349 -0
- polyadmin/core/page.py +62 -0
- polyadmin/core/pagination.py +70 -0
- polyadmin/core/query.py +243 -0
- polyadmin/core/relation.py +40 -0
- polyadmin/core/slug.py +57 -0
- polyadmin/core/template_context.py +679 -0
- polyadmin/core/widget.py +280 -0
- polyadmin/fastapi/__init__.py +3 -0
- polyadmin/fastapi/audit.py +52 -0
- polyadmin/fastapi/auth.py +112 -0
- polyadmin/fastapi/csrf.py +93 -0
- polyadmin/fastapi/deletes.py +76 -0
- polyadmin/fastapi/errors.py +93 -0
- polyadmin/fastapi/handlers.py +798 -0
- polyadmin/fastapi/inlines.py +177 -0
- polyadmin/fastapi/locale.py +128 -0
- polyadmin/fastapi/login.py +110 -0
- polyadmin/fastapi/pages.py +97 -0
- polyadmin/fastapi/relations.py +264 -0
- polyadmin/fastapi/responses.py +55 -0
- polyadmin/fastapi/router.py +174 -0
- polyadmin/fastapi/static.py +21 -0
- polyadmin/i18n/__init__.py +50 -0
- polyadmin/i18n/context.py +54 -0
- polyadmin/i18n/negotiation.py +56 -0
- polyadmin/i18n/setup.py +81 -0
- polyadmin/i18n/translator.py +124 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
- polyadmin/locale/polyadmin.pot +485 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
- polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
- polyadmin/templates/admin/base.html +91 -0
- polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
- polyadmin/templates/admin/components/csrf-field.html +5 -0
- polyadmin/templates/admin/components/error_fragment.html +6 -0
- polyadmin/templates/admin/components/field.html +60 -0
- polyadmin/templates/admin/components/form_wrapper.html +131 -0
- polyadmin/templates/admin/components/icons.html +67 -0
- polyadmin/templates/admin/components/inline.html +251 -0
- polyadmin/templates/admin/components/inline_fragment.html +2 -0
- polyadmin/templates/admin/components/list_content.html +54 -0
- polyadmin/templates/admin/components/lookup_results.html +19 -0
- polyadmin/templates/admin/components/search.html +19 -0
- polyadmin/templates/admin/components/toasts.html +151 -0
- polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
- polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
- polyadmin/templates/admin/components/ui/calendar.html +175 -0
- polyadmin/templates/admin/components/ui/combobox.html +82 -0
- polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
- polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
- polyadmin/templates/admin/components/ui/field.html +110 -0
- polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
- polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
- polyadmin/templates/admin/components/ui/multi-select.html +253 -0
- polyadmin/templates/admin/components/ui/pagination.html +81 -0
- polyadmin/templates/admin/components/ui/radio-group.html +28 -0
- polyadmin/templates/admin/components/ui/select.html +165 -0
- polyadmin/templates/admin/components/ui/sidebar.html +175 -0
- polyadmin/templates/admin/components/ui/slider.html +22 -0
- polyadmin/templates/admin/components/ui/switch.html +36 -0
- polyadmin/templates/admin/components/ui/table.html +221 -0
- polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
- polyadmin/templates/admin/dashboard.html +35 -0
- polyadmin/templates/admin/error.html +33 -0
- polyadmin/templates/admin/login.html +94 -0
- polyadmin/templates/admin/resource/delete.html +29 -0
- polyadmin/templates/admin/resource/delete_selected.html +49 -0
- polyadmin/templates/admin/resource/detail.html +78 -0
- polyadmin/templates/admin/resource/form.html +5 -0
- polyadmin/templates/admin/resource/list.html +5 -0
- polyadmin/templates/admin/theme.html +372 -0
- polyadmin/templates/admin/widgets/activity.html +8 -0
- polyadmin/templates/admin/widgets/chart.html +15 -0
- polyadmin/templates/admin/widgets/donut.html +59 -0
- polyadmin/templates/admin/widgets/metric.html +1 -0
- polyadmin/templates/admin/widgets/progress.html +7 -0
- polyadmin/templates/admin/widgets/stat.html +22 -0
- polyadmin/templates/admin/widgets/table.html +29 -0
- polyadmin/templates/admin/widgets/tabs.html +34 -0
- polyadmin/templates/admin/widgets/timeline.html +21 -0
- polyadmin/templating.py +528 -0
- polyadmin/ui.py +817 -0
- polyadmin-0.1.0b1.dist-info/METADATA +239 -0
- polyadmin-0.1.0b1.dist-info/RECORD +104 -0
- polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
- polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
polyadmin/ui.py
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"""shadcn/ui's variant lookups, resolved server-side.
|
|
2
|
+
|
|
3
|
+
`UI_REGISTRY` resolves shadcn's class-variance-authority (cva) lookups
|
|
4
|
+
while the Jinja template renders, via the `ui` global::
|
|
5
|
+
|
|
6
|
+
{{ ui("button", "outline", "size-sm") }}
|
|
7
|
+
|
|
8
|
+
Each entry splits its class lists the way shadcn does:
|
|
9
|
+
``base``/``variants``/``sizes`` are cva axes and compose; ``parts`` are
|
|
10
|
+
sub-components (CardTitle, TableHead) and *replace* the base.
|
|
11
|
+
|
|
12
|
+
There is no tailwind-merge here, so a base must never set a property its
|
|
13
|
+
variants or sizes also set: two competing utilities resolve by Tailwind's
|
|
14
|
+
output order, not the order written. Parts must also stay space-free
|
|
15
|
+
where classList.add() consumes them (``combobox``'s "item-active"),
|
|
16
|
+
which throws on a value containing a space.
|
|
17
|
+
|
|
18
|
+
Colors are the CSS variables from ``admin/theme.html`` rather than a
|
|
19
|
+
literal palette, which is what makes the admin themeable. Sizes are
|
|
20
|
+
prefixed ``size-`` because shadcn has both a variant and a size named
|
|
21
|
+
"default"; the prefix also tells the resolver which axis was given.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
# Each value is a dict with optional "base" (str) and optional
|
|
26
|
+
# "variants"/"sizes"/"parts" (dict[str, str]) keys.
|
|
27
|
+
UI_REGISTRY: dict[str, dict[str, object]] = {
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
"button": {
|
|
31
|
+
# No height, padding, or background here -- see the uiComponent
|
|
32
|
+
# note on why a base must not fight its own axes.
|
|
33
|
+
"base": "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
|
34
|
+
"variants": {
|
|
35
|
+
# shadcn's six stock button variants, verbatim in intent.
|
|
36
|
+
"default": "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
37
|
+
"destructive": "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
38
|
+
"outline": "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
39
|
+
"secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
40
|
+
"ghost": "text-foreground hover:bg-accent hover:text-accent-foreground",
|
|
41
|
+
"link": "text-primary underline-offset-4 hover:underline",
|
|
42
|
+
# shadcn ships no destructive counterpart to `outline`. A
|
|
43
|
+
# detail page's Delete must match the weight of the outline
|
|
44
|
+
# Edit beside it (solid `destructive` is reserved for the
|
|
45
|
+
# confirmation page's submit), and a row's icon-only Delete
|
|
46
|
+
# wants the colour with no chrome at all.
|
|
47
|
+
"destructive-outline": "border border-destructive/40 bg-background text-destructive hover:bg-destructive hover:text-destructive-foreground",
|
|
48
|
+
"ghost-destructive": "text-destructive hover:bg-destructive/10 hover:text-destructive",
|
|
49
|
+
# Low-emphasis muted ghost: the view/edit row buttons, the
|
|
50
|
+
# sidebar close, the theme toggle.
|
|
51
|
+
"ghost-muted": "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
52
|
+
},
|
|
53
|
+
"sizes": {
|
|
54
|
+
"size-default": "h-10 px-4 py-2",
|
|
55
|
+
"size-sm": "h-9 px-3",
|
|
56
|
+
"size-xs": "h-8 px-2.5 text-xs",
|
|
57
|
+
"size-lg": "h-11 px-8",
|
|
58
|
+
"size-icon": "h-9 w-9 shrink-0",
|
|
59
|
+
"size-icon-sm": "h-8 w-8 shrink-0",
|
|
60
|
+
"size-icon-xs": "h-7 w-7 shrink-0",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
"input": {
|
|
65
|
+
"base": "flex w-full rounded-md border border-input bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
66
|
+
"sizes": {
|
|
67
|
+
"size-default": "h-10 px-3 py-2",
|
|
68
|
+
"size-sm": "h-9 px-2",
|
|
69
|
+
},
|
|
70
|
+
"parts": {
|
|
71
|
+
# A standalone (not composed) control for an input sitting
|
|
72
|
+
# inside an already-bordered wrapper -- the combobox's search
|
|
73
|
+
# field -- so it contributes no border, ring, or background of
|
|
74
|
+
# its own.
|
|
75
|
+
"bare": "flex h-10 w-full bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
"textarea": {
|
|
80
|
+
"base": "flex w-full rounded-md border border-input bg-background text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
81
|
+
"sizes": {"size-default": "min-h-[80px] px-3 py-2"},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
# A native <select>, not shadcn's Radix-backed SelectTrigger: a
|
|
85
|
+
# custom listbox does not post its value with a plain form submit,
|
|
86
|
+
# and the admin's forms have to keep working without JS. Styled to
|
|
87
|
+
# match the shadcn trigger (same height, border, ring).
|
|
88
|
+
"select": {
|
|
89
|
+
"base": "flex w-full items-center rounded-md border border-input bg-background text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
90
|
+
"sizes": {
|
|
91
|
+
"size-default": "h-10 px-3 py-2",
|
|
92
|
+
"size-sm": "h-9 px-2",
|
|
93
|
+
# <select multiple> sizes itself by its `size` attribute.
|
|
94
|
+
"size-auto": "h-auto px-3 py-2",
|
|
95
|
+
},
|
|
96
|
+
"parts": {
|
|
97
|
+
# Table-cell flavours. The base's w-full leaves a <td> select
|
|
98
|
+
# with no intrinsic width, so the column ignores the options
|
|
99
|
+
# and clips the widest ("Enterprise" as "Enterpri"). w-auto
|
|
100
|
+
# lets the browser measure them; max-w-full caps the damage
|
|
101
|
+
# one long option can do. Spelled out in full because a part
|
|
102
|
+
# cannot compose with a size.
|
|
103
|
+
"cell": "flex h-9 w-auto max-w-full items-center rounded-md border border-input bg-background px-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
104
|
+
"cell-multi": "flex h-auto w-auto max-w-full items-center rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
"label": {
|
|
109
|
+
"base": "block text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
# appearance-none is what makes the rest apply: a native checkbox
|
|
113
|
+
# draws itself and ignores border/radius/background. The check glyph
|
|
114
|
+
# is a background image on .ui-checkbox in theme.html.
|
|
115
|
+
"checkbox": {
|
|
116
|
+
"base": "ui-checkbox peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-sm outline-none transition-shadow checked:border-primary checked:bg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
"radio": {
|
|
120
|
+
"base": "h-4 w-4 shrink-0 border-input bg-background accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
# Built on a native checkbox rather than a button, so it toggles with
|
|
124
|
+
# no JavaScript and posts like the checkbox it replaces. track and
|
|
125
|
+
# thumb are siblings of the input, not children: `peer` compiles to a
|
|
126
|
+
# sibling combinator and would not cross into a child.
|
|
127
|
+
"switch": {
|
|
128
|
+
"base": "relative inline-flex shrink-0 cursor-pointer items-center",
|
|
129
|
+
"parts": {
|
|
130
|
+
"input": "peer sr-only",
|
|
131
|
+
"track": "h-5 w-9 rounded-full border-2 border-transparent bg-input transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
132
|
+
"thumb": "pointer-events-none absolute left-0.5 block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform peer-checked:translate-x-4",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
"badge": {
|
|
137
|
+
"base": "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none",
|
|
138
|
+
"variants": {
|
|
139
|
+
"default": "border-transparent bg-primary text-primary-foreground",
|
|
140
|
+
"secondary": "border-transparent bg-secondary text-secondary-foreground",
|
|
141
|
+
"destructive": "border-transparent bg-destructive text-destructive-foreground",
|
|
142
|
+
"outline": "border-border text-foreground",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
"card": {
|
|
147
|
+
"base": "rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
|
148
|
+
"parts": {
|
|
149
|
+
"header": "flex flex-col gap-1.5 p-6",
|
|
150
|
+
"title": "text-sm font-medium leading-none tracking-tight text-muted-foreground",
|
|
151
|
+
"description": "text-sm text-muted-foreground",
|
|
152
|
+
"content": "p-6 pt-0",
|
|
153
|
+
"footer": "flex items-center p-6 pt-0",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
# shadcn's login-04 block. Its social buttons, "Sign up" and "Forgot
|
|
158
|
+
# your password?" are deliberately absent: the framework has no route
|
|
159
|
+
# behind any of them. login-04 fills the right column with a
|
|
160
|
+
# photograph, which a framework cannot ship, so "aside" styles a
|
|
161
|
+
# typographic panel instead.
|
|
162
|
+
"login": {
|
|
163
|
+
"parts": {
|
|
164
|
+
"page": "flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10",
|
|
165
|
+
"container": "flex w-full max-w-sm flex-col gap-6 md:max-w-3xl",
|
|
166
|
+
# Overrides card's own padding: in this block the columns pad
|
|
167
|
+
# themselves, so the card must not.
|
|
168
|
+
"grid": "grid p-0 md:grid-cols-2",
|
|
169
|
+
"form": "p-6 md:p-8",
|
|
170
|
+
"stack": "flex flex-col gap-6",
|
|
171
|
+
"heading": "flex flex-col items-center text-center",
|
|
172
|
+
"title": "text-2xl font-bold",
|
|
173
|
+
"subtitle": "text-balance text-muted-foreground",
|
|
174
|
+
"group": "grid gap-2",
|
|
175
|
+
# bg-muted is the page's own colour, so without login-04's
|
|
176
|
+
# photograph over it the card read as half-width with text
|
|
177
|
+
# floating beside it. The chart tokens are defined in both
|
|
178
|
+
# themes, so a tint from them stays distinct without
|
|
179
|
+
# hardcoding a colour.
|
|
180
|
+
"aside": "relative hidden border-l border-border bg-gradient-to-br from-chart-1/20 via-chart-6/10 to-chart-3/20 md:block",
|
|
181
|
+
"panel": "absolute inset-0 flex flex-col items-center justify-center gap-3 p-8 text-center",
|
|
182
|
+
"logo": "h-12 w-auto opacity-80",
|
|
183
|
+
"brand": "text-xl font-semibold text-foreground",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
# The 401/403/404 page. Parts only, like `login`: a centred card
|
|
188
|
+
# with the status number set large and quiet above the headline, so
|
|
189
|
+
# the page reads as an explanation rather than a stack trace.
|
|
190
|
+
"error": {
|
|
191
|
+
"parts": {
|
|
192
|
+
"page": "flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10",
|
|
193
|
+
"card": "flex w-full max-w-md flex-col items-center gap-3 rounded-lg border border-border bg-card p-8 text-center text-card-foreground shadow-sm",
|
|
194
|
+
"status": "text-5xl font-bold tracking-tight text-muted-foreground/40",
|
|
195
|
+
"title": "text-xl font-semibold",
|
|
196
|
+
"message": "text-balance text-sm text-muted-foreground",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
# The native overflow box, with the scrollbar restyled by the .ui-
|
|
201
|
+
# scroll-area rules in theme.html (a scrollbar cannot be expressed as
|
|
202
|
+
# Tailwind utilities without a plugin). The axis is the whole
|
|
203
|
+
# decision, so there are two parts and no base.
|
|
204
|
+
"scroll-area": {
|
|
205
|
+
"parts": {
|
|
206
|
+
"x": "ui-scroll-area block max-w-xs overflow-x-auto overflow-y-hidden whitespace-nowrap",
|
|
207
|
+
"y": "ui-scroll-area block max-h-40 overflow-y-auto overflow-x-hidden",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
"alert": {
|
|
212
|
+
# `border` with no color; the variants supply the color.
|
|
213
|
+
"base": "relative w-full rounded-lg border p-4",
|
|
214
|
+
"variants": {
|
|
215
|
+
"default": "border-border bg-card text-card-foreground",
|
|
216
|
+
"destructive": "border-destructive/50 bg-destructive/10 text-destructive dark:border-destructive",
|
|
217
|
+
},
|
|
218
|
+
"parts": {
|
|
219
|
+
"title": "mb-1 text-sm font-medium leading-none tracking-tight",
|
|
220
|
+
"description": "text-sm opacity-90",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
"separator": {
|
|
225
|
+
"base": "shrink-0 bg-border",
|
|
226
|
+
"variants": {
|
|
227
|
+
"horizontal": "h-px w-full",
|
|
228
|
+
"vertical": "h-full w-px",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
"skeleton": {"base": "animate-pulse rounded-md bg-muted"},
|
|
233
|
+
|
|
234
|
+
"avatar": {
|
|
235
|
+
"base": "relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full bg-muted",
|
|
236
|
+
"parts": {
|
|
237
|
+
"image": "aspect-square h-full w-full object-cover",
|
|
238
|
+
"fallback": "flex h-full w-full items-center justify-center bg-muted text-xs font-medium text-muted-foreground",
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
# Shared text roles, so "the muted small text" is one decision
|
|
243
|
+
# rather than a repeated literal in twelve templates.
|
|
244
|
+
"text": {
|
|
245
|
+
"parts": {
|
|
246
|
+
"muted": "text-sm text-muted-foreground",
|
|
247
|
+
"muted-xs": "text-xs text-muted-foreground",
|
|
248
|
+
"empty": "text-sm text-muted-foreground",
|
|
249
|
+
"placeholder": "text-muted-foreground",
|
|
250
|
+
"heading": "text-sm font-semibold text-foreground",
|
|
251
|
+
"label-caps": "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
|
|
252
|
+
"metric": "text-3xl font-semibold text-foreground",
|
|
253
|
+
"link": "text-primary underline-offset-4 hover:underline",
|
|
254
|
+
"error": "text-xs font-medium text-destructive",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
"dialog": {
|
|
260
|
+
"parts": {
|
|
261
|
+
"overlay": "fixed inset-0 bg-black/80",
|
|
262
|
+
"container": "fixed inset-0 flex items-center justify-center p-4",
|
|
263
|
+
"content": "relative w-full max-w-lg rounded-lg border border-border bg-background p-6 text-foreground shadow-lg",
|
|
264
|
+
"content-sm": "relative w-full max-w-sm rounded-lg border border-border bg-background p-6 text-foreground shadow-lg",
|
|
265
|
+
"title": "text-base font-semibold leading-none tracking-tight text-foreground",
|
|
266
|
+
"description": "mt-2 text-sm text-muted-foreground",
|
|
267
|
+
"footer": "mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
"dropdown": {
|
|
272
|
+
"parts": {
|
|
273
|
+
"content": "z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md",
|
|
274
|
+
"item": "relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
|
|
275
|
+
"label": "px-2 py-1.5 text-xs font-semibold text-muted-foreground",
|
|
276
|
+
"separator": "-mx-1 my-1 h-px bg-muted",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
"popover": {
|
|
281
|
+
"parts": {
|
|
282
|
+
"content": "z-50 w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
"tooltip": {
|
|
287
|
+
"parts": {
|
|
288
|
+
"content": "z-50 overflow-hidden rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground shadow-md",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
# Sonner, the toaster shadcn/ui ships. Its geometry is kept as it
|
|
293
|
+
# ships: a 356px column in the viewport's corner, 14px between toasts,
|
|
294
|
+
# 16px of padding, 13px text, and a close button parked half outside
|
|
295
|
+
# the top-left corner. What is dropped is Sonner's collapsed
|
|
296
|
+
# peek-stack, which is choreography for an app raising toasts
|
|
297
|
+
# continuously -- see toasts.html.
|
|
298
|
+
"toast": {
|
|
299
|
+
"parts": {
|
|
300
|
+
# pointer-events-none is load-bearing: the viewport spans a
|
|
301
|
+
# strip of the screen even when empty, and record pages put a
|
|
302
|
+
# sticky action bar in that same corner, so it would otherwise
|
|
303
|
+
# swallow clicks on Save. Each toast re-enables pointer events
|
|
304
|
+
# for itself.
|
|
305
|
+
#
|
|
306
|
+
# The padding is Sonner's offset from the viewport edge (32px,
|
|
307
|
+
# 16px below `sm`), so the column inside it is exactly the
|
|
308
|
+
# 356px Sonner uses.
|
|
309
|
+
"list": (
|
|
310
|
+
"pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col "
|
|
311
|
+
"gap-[14px] p-4 sm:inset-x-auto sm:right-0 sm:bottom-0 sm:w-[420px] sm:p-8"
|
|
312
|
+
),
|
|
313
|
+
"root": (
|
|
314
|
+
"pointer-events-auto group relative flex w-full items-center gap-1.5 "
|
|
315
|
+
"rounded-lg border border-border bg-background p-4 text-foreground shadow-lg"
|
|
316
|
+
),
|
|
317
|
+
"icon": "flex size-4 shrink-0 items-center justify-center",
|
|
318
|
+
"title": "text-[13px] font-medium leading-tight",
|
|
319
|
+
"description": "mt-1 text-[13px] leading-snug text-muted-foreground",
|
|
320
|
+
# Sonner parks the close button on the toast's top-left corner,
|
|
321
|
+
# half outside it, and only reveals it on hover.
|
|
322
|
+
"close": (
|
|
323
|
+
"absolute left-0 top-0 flex size-5 -translate-x-1/3 -translate-y-1/3 items-center "
|
|
324
|
+
"justify-center rounded-full border border-border bg-background text-foreground/60 "
|
|
325
|
+
"opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none "
|
|
326
|
+
"focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
|
|
327
|
+
),
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
"sheet": {
|
|
332
|
+
"parts": {
|
|
333
|
+
"overlay": "fixed inset-0 z-40 bg-black/60",
|
|
334
|
+
# Deliberately carries no background or width: the sidebar
|
|
335
|
+
# composes this with `ui "sidebar"`, and two competing
|
|
336
|
+
# bg-*/w-* utilities resolve by Tailwind's output order rather
|
|
337
|
+
# than intent. Panels that aren't the sidebar add "panel".
|
|
338
|
+
"content": "fixed z-50 shadow-lg transition-transform duration-300 ease-in-out",
|
|
339
|
+
"panel": "bg-background",
|
|
340
|
+
"side-left": "inset-y-0 left-0 h-full border-r border-border",
|
|
341
|
+
"side-right": "inset-y-0 right-0 h-full border-l border-border",
|
|
342
|
+
"width-panel": "w-72",
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# shadcn's sidebar-07 block, at its exact widths (16rem open, 3rem
|
|
348
|
+
# collapsed, 18rem mobile sheet). Its --sidebar-* colour scale is
|
|
349
|
+
# deliberately not reproduced: the Zinc values are within a hair of
|
|
350
|
+
# card/accent/border, and restyling the admin must mean editing
|
|
351
|
+
# theme.html's variables and nothing else.
|
|
352
|
+
"sidebar": {
|
|
353
|
+
"base": (
|
|
354
|
+
"flex h-full shrink-0 flex-col border-r border-border bg-card "
|
|
355
|
+
"transition-[width] duration-200 ease-linear"
|
|
356
|
+
),
|
|
357
|
+
"parts": {
|
|
358
|
+
"expanded": "w-64",
|
|
359
|
+
# 3rem exactly: p-2 either side of a size-8 icon button, so
|
|
360
|
+
# the icon column doesn't shift during the transition.
|
|
361
|
+
"collapsed": "w-12",
|
|
362
|
+
"header": "flex flex-col gap-2 p-2",
|
|
363
|
+
"content": "flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden p-2",
|
|
364
|
+
"footer": "flex flex-col gap-2 border-t border-border p-2",
|
|
365
|
+
"group": "flex w-full min-w-0 flex-col",
|
|
366
|
+
"group-label": (
|
|
367
|
+
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs "
|
|
368
|
+
"font-medium text-muted-foreground/70"
|
|
369
|
+
),
|
|
370
|
+
"menu": "flex w-full min-w-0 flex-col gap-1",
|
|
371
|
+
# The sub-menu's left rule is what makes nesting legible once
|
|
372
|
+
# a group is open; it has no icon column of its own.
|
|
373
|
+
"menu-sub": "mx-3.5 flex min-w-0 flex-col gap-1 border-l border-border px-2.5 py-0.5",
|
|
374
|
+
# SidebarRail: the hairline strip on the sidebar's edge that
|
|
375
|
+
# toggles it. Invisible until hovered, hence ::after not a border.
|
|
376
|
+
"rail": (
|
|
377
|
+
"absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-w-resize md:block "
|
|
378
|
+
"after:absolute after:inset-y-0 after:left-1/2 after:w-px "
|
|
379
|
+
"after:transition-colors hover:after:bg-border"
|
|
380
|
+
),
|
|
381
|
+
# SidebarInset: the content column beside the sidebar.
|
|
382
|
+
"inset": "relative flex min-h-0 min-w-0 flex-1 flex-col bg-background",
|
|
383
|
+
# The block's h-16 header: trigger, separator, breadcrumbs.
|
|
384
|
+
"topbar": "flex h-16 shrink-0 items-center gap-2 border-b border-border px-4",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
# SidebarMenuButton. p-2 + a size-4 icon is exactly the collapsed
|
|
389
|
+
# sidebar's 3rem, so nothing shifts horizontally as it animates --
|
|
390
|
+
# only the label clips away.
|
|
391
|
+
"nav-item": {
|
|
392
|
+
# No background here: active/inactive supply it.
|
|
393
|
+
"base": "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
394
|
+
"variants": {
|
|
395
|
+
"active": "bg-accent font-medium text-accent-foreground",
|
|
396
|
+
"inactive": "text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground",
|
|
397
|
+
},
|
|
398
|
+
"sizes": {
|
|
399
|
+
# Base carries padding, not height, so these don't fight it.
|
|
400
|
+
"size-default": "h-8",
|
|
401
|
+
# The taller brand and user buttons at the sidebar's two ends.
|
|
402
|
+
"size-lg": "h-12",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
"tabs": {
|
|
407
|
+
"parts": {
|
|
408
|
+
"list": "inline-flex h-9 items-center justify-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
|
|
409
|
+
"trigger": "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
410
|
+
"trigger-on": "bg-background text-foreground shadow-sm",
|
|
411
|
+
"trigger-off": "text-muted-foreground hover:text-foreground",
|
|
412
|
+
# Underline flavor, for the dashboard's Tabs container widget
|
|
413
|
+
# -- a pill row would fight the card it sits inside.
|
|
414
|
+
"underline-list": "mb-3 flex gap-1 border-b border-border",
|
|
415
|
+
"underline-trigger": "-mb-px border-b-2 px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
416
|
+
"underline-trigger-on": "border-primary text-foreground",
|
|
417
|
+
"underline-trigger-off": "border-transparent text-muted-foreground hover:text-foreground",
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
"accordion": {
|
|
422
|
+
"parts": {
|
|
423
|
+
"item": "border-b border-border",
|
|
424
|
+
"trigger": "flex w-full flex-1 items-center justify-between gap-3 py-3 text-sm font-medium text-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
425
|
+
"content": "overflow-hidden pb-3 text-sm text-muted-foreground",
|
|
426
|
+
"chevron": "shrink-0 transition-transform duration-200",
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
"breadcrumb": {
|
|
431
|
+
"base": "flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground",
|
|
432
|
+
"parts": {
|
|
433
|
+
"item": "flex items-center gap-1.5",
|
|
434
|
+
"link": "rounded transition-colors hover:text-foreground",
|
|
435
|
+
"page": "cursor-default font-semibold text-foreground",
|
|
436
|
+
"plain": "cursor-default",
|
|
437
|
+
"separator": "text-border",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
# Toolbar + footer of the list table, from shadcn's Tasks example:
|
|
442
|
+
# search and filters left, page actions right; below, the
|
|
443
|
+
# DataTablePagination row.
|
|
444
|
+
#
|
|
445
|
+
# Stacked until lg, a row from lg up. lg, not sm, because the sidebar
|
|
446
|
+
# becomes a static 16rem column at md: the content area *shrinks*
|
|
447
|
+
# there (735px -> 549px), and measured, the five controls only stop
|
|
448
|
+
# wrapping into a ragged block at ~1024px.
|
|
449
|
+
"toolbar": {
|
|
450
|
+
"base": "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between",
|
|
451
|
+
"parts": {
|
|
452
|
+
# Every control carries its own "item" to fill its line while
|
|
453
|
+
# stacked: stretch alone cannot, since several are buttons
|
|
454
|
+
# wrapped in a <form> or a positioning <div>. flex-1 so the
|
|
455
|
+
# filter cluster takes the slack once they are rows.
|
|
456
|
+
"filters": "flex flex-1 flex-col gap-2 lg:flex-row lg:flex-wrap lg:items-center",
|
|
457
|
+
"actions": "flex flex-col gap-2 lg:flex-row lg:items-center",
|
|
458
|
+
"item": "w-full lg:w-auto",
|
|
459
|
+
# Centred content in a full-width bar reads as floating, so
|
|
460
|
+
# while stacked the label goes hard left and the icon hard
|
|
461
|
+
# right. "item-label" takes the slack (which also leaves the
|
|
462
|
+
# base's justify-center nothing to centre, so the two never
|
|
463
|
+
# fight); "item-icon" moves a leading icon to the trailing
|
|
464
|
+
# edge without reordering the markup. Both revert at lg.
|
|
465
|
+
"item-label": "flex-1 text-left lg:flex-none",
|
|
466
|
+
"item-icon": "order-last lg:order-none",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
# The filter drawer: one Filters trigger in the toolbar opening a
|
|
470
|
+
# sheet from the right. One trigger stays one trigger however many
|
|
471
|
+
# filters a ModelAdmin declares, which per-filter dropdowns don't.
|
|
472
|
+
"filter-panel": {
|
|
473
|
+
"parts": {
|
|
474
|
+
"header": "flex items-center justify-between gap-2 border-b border-border px-4 py-3",
|
|
475
|
+
"title": "text-sm font-semibold text-foreground",
|
|
476
|
+
"body": "flex-1 space-y-5 overflow-y-auto px-4 py-4",
|
|
477
|
+
"group-label": (
|
|
478
|
+
"mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
479
|
+
),
|
|
480
|
+
"choice": (
|
|
481
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors "
|
|
482
|
+
"hover:bg-accent hover:text-accent-foreground"
|
|
483
|
+
),
|
|
484
|
+
"choice-active": "bg-accent font-medium text-accent-foreground",
|
|
485
|
+
"choice-inactive": "text-muted-foreground",
|
|
486
|
+
"footer": "border-t border-border px-4 py-3",
|
|
487
|
+
# The count badge on the trigger, shown only while filters
|
|
488
|
+
# are actually narrowing the list.
|
|
489
|
+
"count": "rounded-sm px-1 font-mono text-xs font-normal",
|
|
490
|
+
# The range form under a date filter's presets.
|
|
491
|
+
"form": "mt-2 space-y-2 border-t border-border px-3 pt-3",
|
|
492
|
+
"form-row": "flex items-center gap-2",
|
|
493
|
+
"form-label": "w-10 shrink-0 text-xs text-muted-foreground",
|
|
494
|
+
"form-input": "h-8 flex-1 text-sm",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
"pagination": {
|
|
499
|
+
"base": "flex flex-col items-center gap-3 sm:flex-row sm:justify-between",
|
|
500
|
+
"parts": {
|
|
501
|
+
"list": "flex h-9 items-center divide-x divide-border overflow-hidden rounded-md border border-border bg-card text-sm leading-tight text-muted-foreground",
|
|
502
|
+
"link": "relative inline-flex h-full items-center px-3 transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
503
|
+
"disabled": "pointer-events-none text-muted-foreground/40",
|
|
504
|
+
"current": "hidden h-full items-center px-3 font-medium text-foreground sm:flex",
|
|
505
|
+
# The selection count, which stays visible (as "0 of N")
|
|
506
|
+
# even with nothing selected, exactly as the example does.
|
|
507
|
+
"selection": "flex-1 text-sm text-muted-foreground",
|
|
508
|
+
"controls": "flex items-center gap-4 lg:gap-8",
|
|
509
|
+
"rows-per-page": "flex items-center gap-2 text-sm font-medium",
|
|
510
|
+
# shadcn fixes this at w-[100px]; a translated "Page 1 of 8"
|
|
511
|
+
# is wider than that in most languages, so the width is a
|
|
512
|
+
# floor and the label never wraps to a second line.
|
|
513
|
+
"page-indicator": (
|
|
514
|
+
"flex min-w-[100px] items-center justify-center whitespace-nowrap "
|
|
515
|
+
"text-sm font-medium"
|
|
516
|
+
),
|
|
517
|
+
"jumps": "flex items-center gap-2",
|
|
518
|
+
# The two outer jumps (first/last) are hidden on small
|
|
519
|
+
# screens in the example -- prev/next are enough there.
|
|
520
|
+
"jump-edge": "hidden h-8 w-8 p-0 lg:flex",
|
|
521
|
+
"jump": "h-8 w-8 p-0",
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
"table": {
|
|
526
|
+
"base": "w-full min-w-full caption-bottom text-sm",
|
|
527
|
+
"parts": {
|
|
528
|
+
"wrapper": "overflow-hidden rounded-lg border border-border bg-card",
|
|
529
|
+
"scroll": "overflow-x-auto",
|
|
530
|
+
# The "select all N matching" strip: only ever visible once
|
|
531
|
+
# a whole page is ticked, so it reads as a follow-up
|
|
532
|
+
# question rather than permanent chrome.
|
|
533
|
+
"select-all": "flex flex-wrap items-center justify-center gap-2 border-b border-border bg-muted/40 px-4 py-2 text-center text-sm text-muted-foreground",
|
|
534
|
+
"select-all-action": "font-medium text-foreground underline underline-offset-2 hover:no-underline",
|
|
535
|
+
"head": "[&_tr]:border-b [&_tr]:border-border",
|
|
536
|
+
"th": "px-4 py-2.5 text-left align-middle font-medium whitespace-nowrap text-muted-foreground",
|
|
537
|
+
"body": "divide-y divide-border",
|
|
538
|
+
# data-[state=selected], from shadcn/ui's own TableRow: a
|
|
539
|
+
# checked row-checkbox (list_content.html) tints the whole
|
|
540
|
+
# row, the same as a hover, so a selection reads at a glance
|
|
541
|
+
# instead of only through the toolbar's "N of M selected"
|
|
542
|
+
# count.
|
|
543
|
+
"row": "text-foreground transition-colors hover:bg-muted/50 has-[.row-checkbox:checked]:bg-muted",
|
|
544
|
+
"cell": "px-4 py-2.5 align-middle text-sm whitespace-nowrap",
|
|
545
|
+
"empty": "px-4 py-6 text-sm text-muted-foreground",
|
|
546
|
+
# Compact flavor for the dashboard Table widget and tabular
|
|
547
|
+
# inlines, which sit inside an existing card.
|
|
548
|
+
"th-compact": "px-3 py-2 text-left text-xs font-medium whitespace-nowrap text-muted-foreground",
|
|
549
|
+
"cell-compact": "px-3 py-2 align-middle whitespace-nowrap",
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# The label + control + description + error unit. shadcn calls this
|
|
555
|
+
# FormItem/FormLabel/FormDescription/FormMessage; here it is what
|
|
556
|
+
# wraps every generated form input.
|
|
557
|
+
"field": {
|
|
558
|
+
"base": "mb-4",
|
|
559
|
+
"parts": {
|
|
560
|
+
"label": "block text-sm font-medium leading-none text-foreground",
|
|
561
|
+
"required": "ml-0.5 text-destructive",
|
|
562
|
+
"control": "mt-1.5",
|
|
563
|
+
"description": "mt-1.5 text-xs text-muted-foreground",
|
|
564
|
+
"message": "mt-1.5 text-xs font-medium text-destructive",
|
|
565
|
+
# A boolean puts its control on the same row as its name.
|
|
566
|
+
# items-start, not items-center: with a description the text
|
|
567
|
+
# block is two lines and centring leaves the toggle sitting
|
|
568
|
+
# low. row-label's 20px line box matches the switch height, so
|
|
569
|
+
# top-aligning reads as centred either way.
|
|
570
|
+
"row": "flex items-start gap-2",
|
|
571
|
+
"row-text": "min-w-0",
|
|
572
|
+
"row-label": "leading-5",
|
|
573
|
+
# A read-only field's value, shown instead of a control.
|
|
574
|
+
# Deliberately not input-shaped: a disabled-looking box
|
|
575
|
+
# invites clicking at it, a plain value does not.
|
|
576
|
+
"readonly": "py-1.5 text-sm text-foreground",
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
"combobox": {
|
|
581
|
+
"base": "relative",
|
|
582
|
+
"parts": {
|
|
583
|
+
"trigger": "flex items-center gap-2 rounded-md border border-input bg-background px-3 ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
584
|
+
# ui-scroll-area, so a long option list scrolls behind the same
|
|
585
|
+
# thin token-coloured bar the rest of the admin uses rather than
|
|
586
|
+
# the platform's own chunky one -- as close as CSS gets to Radix
|
|
587
|
+
# drawing its own over a hidden native one.
|
|
588
|
+
"content": "ui-scroll-area absolute z-50 mt-1 max-h-[280px] w-full overflow-y-auto rounded-md border border-border bg-popover py-1 text-sm text-popover-foreground shadow-md",
|
|
589
|
+
"item": "relative flex cursor-pointer select-none items-center rounded-sm px-3 py-1.5 text-popover-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
590
|
+
# Handed to classList.add()/remove() by the arrow-key
|
|
591
|
+
# handler, so this must stay a single space-free class.
|
|
592
|
+
"item-active": "bg-accent",
|
|
593
|
+
"empty": "px-3 py-1.5 text-muted-foreground",
|
|
594
|
+
"icon": "w-4 h-4 shrink-0 text-muted-foreground",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
# The many-to-many control: shadcn's Combobox/Command idiom over the
|
|
599
|
+
# relation's options, selection shown as removable chips. Django's
|
|
600
|
+
# filter_horizontal without the two-pane layout, which needs width
|
|
601
|
+
# this form column doesn't have.
|
|
602
|
+
"multi-select": {
|
|
603
|
+
"parts": {
|
|
604
|
+
# min-h matches the single Select's h-10 so a field with
|
|
605
|
+
# nothing chosen lines up with its neighbours; it grows from
|
|
606
|
+
# there as chips wrap onto more lines.
|
|
607
|
+
"trigger": "min-h-10 justify-between gap-2 text-left",
|
|
608
|
+
"values": "flex flex-1 flex-wrap items-center gap-1",
|
|
609
|
+
"chip": "gap-1 py-0.5 pr-1 pl-2 font-normal",
|
|
610
|
+
# The chip's own remove affordance. Not a <button>: the chip
|
|
611
|
+
# lives inside the trigger <button>, and HTML forbids nesting
|
|
612
|
+
# one button in another.
|
|
613
|
+
"chip-remove": "rounded-sm p-0.5 transition-colors hover:bg-background/60",
|
|
614
|
+
"search": "flex items-center gap-2 border-b border-border px-3",
|
|
615
|
+
"list": "max-h-56 overflow-y-auto py-1",
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
"calendar": {
|
|
620
|
+
"base": "w-auto p-3",
|
|
621
|
+
"parts": {
|
|
622
|
+
"header": "flex items-center justify-between gap-2 pb-2",
|
|
623
|
+
"caption": "text-sm font-medium text-foreground",
|
|
624
|
+
"nav": "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
625
|
+
"grid": "w-full border-collapse",
|
|
626
|
+
"weekday": "h-8 w-8 p-0 text-center text-[0.7rem] font-normal text-muted-foreground",
|
|
627
|
+
# "day" carries no background so the three state parts below
|
|
628
|
+
# can be layered on via an Alpine :class binding.
|
|
629
|
+
"day": "h-8 w-8 rounded-md p-0 text-center text-sm font-normal text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
630
|
+
"day-selected": "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
|
631
|
+
"day-today": "border border-border font-semibold",
|
|
632
|
+
"day-outside": "text-muted-foreground/50",
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
"slider": {
|
|
637
|
+
"base": "relative flex w-full touch-none select-none items-center",
|
|
638
|
+
"parts": {
|
|
639
|
+
# A native range input, tinted with accent-color -- same
|
|
640
|
+
# no-JS-form reasoning as checkbox/radio.
|
|
641
|
+
"track": "h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
|
642
|
+
"output": "w-12 shrink-0 text-right text-sm tabular-nums text-muted-foreground",
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
"widget": {
|
|
648
|
+
"base": "rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-6",
|
|
649
|
+
"parts": {
|
|
650
|
+
"header": "mb-3 flex items-center gap-3",
|
|
651
|
+
"icon": "inline-flex shrink-0 items-center justify-center rounded-lg bg-muted p-2 text-foreground",
|
|
652
|
+
"title": "text-sm font-medium text-muted-foreground",
|
|
653
|
+
# The content area. The dashboard is a grid, so a card that
|
|
654
|
+
# grows with its content drags every other card in its row to
|
|
655
|
+
# the same height; bounding the body instead keeps a long list
|
|
656
|
+
# inside its own card and scrolls it there, with theme.html's
|
|
657
|
+
# .ui-scroll-area styling the bar.
|
|
658
|
+
#
|
|
659
|
+
# It scrolls in one direction only. A card is a fixed column of
|
|
660
|
+
# a grid, so anything wider than it is a widget that has to
|
|
661
|
+
# wrap, not a second scrollbar to drag: widgets/table.html
|
|
662
|
+
# wraps its cells rather than holding them on one line.
|
|
663
|
+
"body": "ui-scroll-area max-h-80 overflow-y-auto overflow-x-hidden",
|
|
664
|
+
"span-lg": "sm:col-span-2 xl:col-span-3",
|
|
665
|
+
"bar-track": "h-2 w-full rounded-full bg-muted",
|
|
666
|
+
"bar-fill": "h-2 rounded-full bg-primary",
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
# Form fieldset (Django's `fieldsets`): a titled, collapsible group
|
|
671
|
+
# of fields inside the record form. A group with no title renders
|
|
672
|
+
# bare -- no header, no border -- so the default single-group case
|
|
673
|
+
# looks exactly like the flat form it replaces.
|
|
674
|
+
"fieldset": {
|
|
675
|
+
"base": "mb-6 last:mb-0 rounded-lg border border-border bg-muted/20",
|
|
676
|
+
"parts": {
|
|
677
|
+
"trigger": "flex w-full items-center justify-between gap-2 rounded-t-lg px-4 py-3 text-left transition-colors hover:bg-muted/40",
|
|
678
|
+
"title": "text-sm font-semibold text-foreground",
|
|
679
|
+
"description": "px-4 pb-2 text-sm text-muted-foreground",
|
|
680
|
+
"body": "border-t border-border p-4",
|
|
681
|
+
"icon": "size-4 shrink-0 text-muted-foreground transition-transform",
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
# The detail page's History panel: a compact recent-activity list,
|
|
686
|
+
# shown only when the configured audit logger can read back.
|
|
687
|
+
"history": {
|
|
688
|
+
"base": "mt-6 rounded-lg border border-border bg-card",
|
|
689
|
+
"parts": {
|
|
690
|
+
"title": "border-b border-border px-4 py-2.5 text-sm font-semibold text-foreground",
|
|
691
|
+
"row": "flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 px-4 py-2 text-sm",
|
|
692
|
+
"when": "text-muted-foreground tabular-nums",
|
|
693
|
+
"who": "font-medium text-foreground",
|
|
694
|
+
"what": "text-muted-foreground",
|
|
695
|
+
"empty": "px-4 py-3 text-sm text-muted-foreground",
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
"panel": {
|
|
700
|
+
"base": "rounded-lg border border-border bg-card p-4 text-card-foreground",
|
|
701
|
+
"parts": {
|
|
702
|
+
"dashed": "rounded-lg border border-dashed border-border bg-muted/40 p-4 text-sm text-muted-foreground",
|
|
703
|
+
"form": "w-full rounded-lg border border-border bg-card p-7 text-card-foreground shadow-sm",
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
# Record-page shell for detail/create/edit: one full-height column
|
|
707
|
+
# holding the record and an action bar pinned to the bottom of the
|
|
708
|
+
# viewport. Used by resource/detail.html and components/
|
|
709
|
+
# form_wrapper.html so the two never disagree about width or
|
|
710
|
+
# alignment.
|
|
711
|
+
"page": {
|
|
712
|
+
# min-h-full (not h-full) is what lets the column grow past the
|
|
713
|
+
# viewport for a long record instead of clipping it.
|
|
714
|
+
"base": "flex min-h-full flex-col",
|
|
715
|
+
"parts": {
|
|
716
|
+
# my-auto gives "centred when it fits, scrolls when it
|
|
717
|
+
# doesn't" with no media query: free space splits above and
|
|
718
|
+
# below, and collapses to 0 once the content overflows.
|
|
719
|
+
"body": "mx-auto my-auto w-full max-w-xl space-y-4",
|
|
720
|
+
# max-w-xl is the measure for a column of form fields, but a
|
|
721
|
+
# tabular inline is a table: squeezing one into 576px is what
|
|
722
|
+
# clipped its own row actions, so a page holding one gets the
|
|
723
|
+
# wider cap.
|
|
724
|
+
"body-wide": "mx-auto my-auto w-full max-w-5xl space-y-4",
|
|
725
|
+
# sticky rather than fixed: it reserves its own space inside
|
|
726
|
+
# <main>'s scroll container, so it needs no sidebar-width
|
|
727
|
+
# offset and no compensating bottom padding. The negative
|
|
728
|
+
# margins cancel <main>'s p-4 so it spans the full width.
|
|
729
|
+
"actions": (
|
|
730
|
+
"sticky bottom-0 z-10 -mx-4 -mb-4 mt-4 border-t border-border "
|
|
731
|
+
"bg-background/95 px-4 py-3 backdrop-blur"
|
|
732
|
+
),
|
|
733
|
+
# Matched to "body"'s max-w-xl so the buttons line up with the
|
|
734
|
+
# record. flex-col-reverse below sm floats the primary group
|
|
735
|
+
# to the top, so Delete is never the button under your thumb
|
|
736
|
+
# when the bar stacks.
|
|
737
|
+
"actions-inner": (
|
|
738
|
+
"mx-auto flex w-full max-w-xl flex-col-reverse gap-2 "
|
|
739
|
+
"sm:flex-row sm:items-center"
|
|
740
|
+
),
|
|
741
|
+
# Matched to "body-wide" for the same reason "actions-inner"
|
|
742
|
+
# is matched to "body": the bar has to line up with the card
|
|
743
|
+
# above it, so widening one without the other leaves the
|
|
744
|
+
# buttons drifting.
|
|
745
|
+
"actions-inner-wide": (
|
|
746
|
+
"mx-auto flex w-full max-w-5xl flex-col-reverse gap-2 "
|
|
747
|
+
"sm:flex-row sm:items-center"
|
|
748
|
+
),
|
|
749
|
+
# The right-hand group. sm:ml-auto does the separating, so
|
|
750
|
+
# the bar reads Delete-left / everything-else-right when a
|
|
751
|
+
# Delete is present and simply right-aligns when it isn't
|
|
752
|
+
# (create, and detail once Delete moved to the edit page).
|
|
753
|
+
"actions-primary": "flex flex-col gap-2 sm:ml-auto sm:flex-row",
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class UnknownUIVariant(KeyError):
|
|
760
|
+
"""Raised for an unknown component or modifier.
|
|
761
|
+
|
|
762
|
+
A typo raises rather than resolving to an empty class string, so it
|
|
763
|
+
surfaces in the adapter's own render tests instead of shipping an
|
|
764
|
+
unstyled button.
|
|
765
|
+
"""
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _group(component: str, name: str) -> dict[str, str]:
|
|
769
|
+
return UI_REGISTRY[component].get(name, {}) # type: ignore[return-value]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def ui(component: str, *modifiers: str) -> str:
|
|
773
|
+
"""Resolve a component's class string from :data:`UI_REGISTRY`.
|
|
774
|
+
|
|
775
|
+
A modifier naming a part resolves to that part alone; variant and
|
|
776
|
+
size modifiers compose with the base, defaulting whichever axis was
|
|
777
|
+
left out. Registered as the ``ui`` Jinja global.
|
|
778
|
+
"""
|
|
779
|
+
if component not in UI_REGISTRY:
|
|
780
|
+
known = ", ".join(sorted(UI_REGISTRY))
|
|
781
|
+
raise UnknownUIVariant(f"unknown ui component {component!r} (known: {known})")
|
|
782
|
+
|
|
783
|
+
variants = _group(component, "variants")
|
|
784
|
+
sizes = _group(component, "sizes")
|
|
785
|
+
parts = _group(component, "parts")
|
|
786
|
+
|
|
787
|
+
# A single part reference stands alone.
|
|
788
|
+
if len(modifiers) == 1 and modifiers[0] in parts:
|
|
789
|
+
return parts[modifiers[0]]
|
|
790
|
+
|
|
791
|
+
base = UI_REGISTRY[component].get("base", "")
|
|
792
|
+
resolved: list[str] = [base] if base else []
|
|
793
|
+
|
|
794
|
+
has_size = any(m.startswith("size-") for m in modifiers)
|
|
795
|
+
has_variant = any(not m.startswith("size-") for m in modifiers)
|
|
796
|
+
if not has_variant and "default" in variants:
|
|
797
|
+
resolved.append(variants["default"])
|
|
798
|
+
if not has_size and "size-default" in sizes:
|
|
799
|
+
resolved.append(sizes["size-default"])
|
|
800
|
+
|
|
801
|
+
for modifier in modifiers:
|
|
802
|
+
if modifier in variants:
|
|
803
|
+
resolved.append(variants[modifier])
|
|
804
|
+
elif modifier in sizes:
|
|
805
|
+
resolved.append(sizes[modifier])
|
|
806
|
+
elif modifier in parts:
|
|
807
|
+
raise UnknownUIVariant(
|
|
808
|
+
f"ui {component!r} part {modifier!r} cannot be combined with other "
|
|
809
|
+
"modifiers (a part replaces the base; request it on its own)"
|
|
810
|
+
)
|
|
811
|
+
else:
|
|
812
|
+
known = ", ".join(sorted({**variants, **sizes, **parts}))
|
|
813
|
+
raise UnknownUIVariant(
|
|
814
|
+
f"ui component {component!r} has no modifier {modifier!r} (known: {known})"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
return " ".join(p for p in resolved if p)
|