@webority/theme 0.3.0 → 0.4.0

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,284 @@
1
+ // Drawer / sidebar menu, tabs, search field.
2
+ //
3
+ // Part of the wui-components layer — see _wui-components.scss for the whole
4
+ // picture and the load order. Order within the layer is LOAD-BEARING: these
5
+ // partials are a split of one stylesheet, not independent modules, so a rule
6
+ // here may be overridden by a later partial. Never reorder the imports.
7
+
8
+ // ── Drawer / Sidebar menu ──
9
+ // AppSidebarMenu — react-bootstrap Offcanvas mapped onto the app tokens plus
10
+ // a flex column layout for the header / scrolling body / sticky footer.
11
+ .app-sidebar-menu.offcanvas {
12
+ --bs-offcanvas-width: var(--asm-width, 420px);
13
+ --bs-offcanvas-bg: var(--color-surface);
14
+ --bs-offcanvas-color: var(--color-text);
15
+ --bs-offcanvas-border-color: var(--color-border);
16
+ --bs-offcanvas-border-width: 1px;
17
+ --bs-offcanvas-padding-x: 0;
18
+ --bs-offcanvas-padding-y: 0;
19
+ max-width: 100vw;
20
+ display: flex;
21
+ flex-direction: column;
22
+ }
23
+ .app-sidebar-menu .offcanvas-body {
24
+ padding: 0;
25
+ display: flex;
26
+ flex-direction: column;
27
+ flex: 1;
28
+ min-height: 0;
29
+ }
30
+ .app-sidebar-menu .asm-footer {
31
+ padding: 16px 20px;
32
+ border-top: 1px solid var(--color-border);
33
+ background: var(--color-surface);
34
+ }
35
+
36
+ .drawer {
37
+ position: fixed;
38
+ top: 0;
39
+ right: 0;
40
+ width: 420px;
41
+ max-width: 100vw;
42
+ height: 100vh;
43
+ background: var(--color-surface);
44
+ border-left: 1px solid var(--color-border);
45
+ z-index: 200;
46
+ transform: translateX(100%);
47
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
48
+ display: flex;
49
+ flex-direction: column;
50
+ }
51
+ .drawer.open {
52
+ transform: translateX(0);
53
+ }
54
+ .drawer-backdrop {
55
+ position: fixed;
56
+ inset: 0;
57
+ background: rgba(15, 23, 42, 0.4);
58
+ opacity: 0;
59
+ pointer-events: none;
60
+ transition: opacity 0.2s;
61
+ z-index: 150;
62
+ }
63
+ .drawer-backdrop.open {
64
+ opacity: 1;
65
+ pointer-events: auto;
66
+ }
67
+ .drawer-header {
68
+ padding: 18px 20px;
69
+ border-bottom: 1px solid var(--color-border);
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ background: var(--color-surface);
74
+ }
75
+ .drawer-title {
76
+ font-weight: 600;
77
+ font-size: 16px;
78
+ letter-spacing: -0.01em;
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 10px;
82
+ color: var(--color-text);
83
+ }
84
+ .drawer-title-ic {
85
+ flex: none;
86
+ display: grid;
87
+ place-items: center;
88
+ width: 30px;
89
+ height: 30px;
90
+ border-radius: var(--radius);
91
+ corner-shape: squircle;
92
+ background: var(--color-primary-soft);
93
+ color: var(--color-primary);
94
+ }
95
+ // Sticky tools zone: search + section meta stay put while the list scrolls.
96
+ .drawer-tools {
97
+ padding: 16px 20px 12px;
98
+ border-bottom: 1px solid var(--color-border);
99
+ background: var(--color-surface);
100
+ }
101
+ .drawer-body {
102
+ flex: 1;
103
+ overflow-y: auto;
104
+ padding: 14px 16px 20px;
105
+ }
106
+
107
+ // ── Tabs ──
108
+ // The tab strip both surfaces render. React drives it as a controlled strip and
109
+ // Razor additionally renders Bootstrap panels beneath it, but the strip markup —
110
+ // and therefore the look — is identical.
111
+ .app-tabs {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 2px;
115
+ border-bottom: 1px solid var(--color-border);
116
+ overflow-x: auto;
117
+ scrollbar-width: none;
118
+ // The React AppTabs renders a react-bootstrap <Nav>, so the element also
119
+ // carries `.nav`, which declares `flex-wrap: wrap`. Both are (0,1,0), so
120
+ // today this wins only because the theme is imported after Bootstrap —
121
+ // see the doubled-selector rule below, which does not depend on that.
122
+ flex-wrap: nowrap;
123
+ }
124
+ // Doubled to (0,2,0) so the tab strip keeps scrolling even if a consumer loads
125
+ // Bootstrap after the theme, which would otherwise let `.nav` win on order.
126
+ .app-tabs.app-tabs {
127
+ flex-wrap: nowrap;
128
+ }
129
+ .app-tabs::-webkit-scrollbar {
130
+ display: none;
131
+ }
132
+ .app-tab {
133
+ display: inline-flex;
134
+ align-items: center;
135
+ gap: 8px;
136
+ padding: 10px 14px;
137
+ border: 0;
138
+ // Sits on the strip's own hairline rather than adding a second one.
139
+ border-bottom: 2px solid transparent;
140
+ margin-bottom: -1px;
141
+ background: transparent;
142
+ font-family: inherit;
143
+ font-size: 14px;
144
+ font-weight: 500;
145
+ line-height: 1.4;
146
+ color: var(--color-text-muted);
147
+ white-space: nowrap;
148
+ cursor: pointer;
149
+ transition:
150
+ color 0.12s ease,
151
+ border-color 0.12s ease;
152
+ }
153
+ .app-tab:hover:not(:disabled):not(.active) {
154
+ color: var(--color-text);
155
+ }
156
+ .app-tab.active {
157
+ color: var(--color-primary);
158
+ border-bottom-color: var(--color-primary);
159
+ // The active tab carries weight as well as colour — colour alone is a weak
160
+ // signal for anyone who reads the strip at a glance or cannot distinguish it.
161
+ font-weight: 600;
162
+ }
163
+ .app-tab:disabled {
164
+ color: var(--color-text-subtle);
165
+ cursor: not-allowed;
166
+ }
167
+ .app-tab:focus-visible {
168
+ outline: 2px solid var(--color-primary);
169
+ outline-offset: -2px;
170
+ border-radius: var(--radius-sm);
171
+ }
172
+ // A destructive tab reads as destructive whether or not it is the active one.
173
+ .app-tab.danger {
174
+ color: var(--color-danger);
175
+ }
176
+ .app-tab.danger:hover:not(:disabled):not(.active) {
177
+ color: var(--color-danger-active);
178
+ }
179
+ .app-tab.danger.active {
180
+ color: var(--color-danger);
181
+ border-bottom-color: var(--color-danger);
182
+ }
183
+ .app-tab.danger .app-tab-count {
184
+ background: var(--color-danger-soft);
185
+ color: var(--color-danger-strong);
186
+ }
187
+ // Trailing count pill.
188
+ .app-tab-count {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ min-width: 20px;
193
+ padding: 0 6px;
194
+ height: 20px;
195
+ border-radius: 999px;
196
+ background: var(--color-bg-muted);
197
+ color: var(--color-text-subtle);
198
+ font-size: 12px;
199
+ font-weight: 600;
200
+ }
201
+ .app-tab.active .app-tab-count {
202
+ background: var(--color-primary-soft);
203
+ color: var(--color-primary);
204
+ }
205
+
206
+ // Wrapper for a self-contained tabs widget (strip + panels), as <app-tabs>
207
+ // renders server-side. React composes the strip and its panels itself.
208
+ .app-tabs-group > .tab-content {
209
+ padding-top: 16px;
210
+ }
211
+
212
+ // Pills variant — a segmented control rather than an underline.
213
+ .app-tabs-pills {
214
+ gap: 4px;
215
+ border-bottom: 0;
216
+ }
217
+ .app-tabs-pills .app-tab {
218
+ border-bottom: 0;
219
+ margin-bottom: 0;
220
+ border-radius: var(--radius);
221
+ }
222
+ .app-tabs-pills .app-tab.active {
223
+ background: var(--color-primary-soft);
224
+ }
225
+
226
+ // ── Search field ──
227
+ // The search control both surfaces render: a rounded field with a leading
228
+ // magnifier that takes the brand colour on focus, plus a clear button that
229
+ // appears once there's a value.
230
+ .search-wrap {
231
+ position: relative;
232
+ display: flex;
233
+ align-items: center;
234
+ width: 100%;
235
+ }
236
+ .search-wrap > .search-icon {
237
+ position: absolute;
238
+ left: 13px;
239
+ top: 50%;
240
+ transform: translateY(-50%);
241
+ display: grid;
242
+ place-items: center;
243
+ color: var(--color-text-subtle);
244
+ pointer-events: none;
245
+ transition: color 0.12s ease;
246
+ }
247
+ .search-wrap:focus-within > .search-icon {
248
+ color: var(--color-primary);
249
+ }
250
+ .search-field,
251
+ .search-input {
252
+ width: 100%;
253
+ padding-left: 40px;
254
+ padding-right: 36px;
255
+ }
256
+ .search-clear {
257
+ position: absolute;
258
+ right: 8px;
259
+ top: 50%;
260
+ transform: translateY(-50%);
261
+ display: grid;
262
+ place-items: center;
263
+ width: 26px;
264
+ height: 26px;
265
+ padding: 0;
266
+ border: 0;
267
+ border-radius: 999px;
268
+ background: transparent;
269
+ color: var(--color-text-subtle);
270
+ cursor: pointer;
271
+ opacity: 0;
272
+ pointer-events: none;
273
+ transition:
274
+ opacity 0.12s ease,
275
+ color 0.12s ease;
276
+ }
277
+ .search-wrap.has-value .search-clear,
278
+ .search-wrap:focus-within .search-clear {
279
+ opacity: 1;
280
+ pointer-events: auto;
281
+ }
282
+ .search-clear:hover {
283
+ color: var(--color-text);
284
+ }
@@ -0,0 +1,350 @@
1
+ // Modal, confirm dialog.
2
+ //
3
+ // Part of the wui-components layer — see _wui-components.scss for the whole
4
+ // picture and the load order. Order within the layer is LOAD-BEARING: these
5
+ // partials are a split of one stylesheet, not independent modules, so a rule
6
+ // here may be overridden by a later partial. Never reorder the imports.
7
+
8
+ // ── Modal ──
9
+ // Chrome stays on `.lx-modal-*`. The shell is react-bootstrap <Modal>:
10
+ // .modal.lx-modal-shell ← className (layout container)
11
+ // .modal-backdrop.lx-modal-dim ← backdropClassName (dim only)
12
+ // .modal-dialog.lx-rb-dialog ← dialogClassName
13
+ // .modal-content.lx-modal ← contentClassName (the card)
14
+ // Do NOT put layout on backdropClassName — that targets a sibling, not
15
+ // .modal.
16
+ .modal-backdrop.lx-modal-dim {
17
+ background: rgba(15, 23, 42, 0.45);
18
+ z-index: 1500;
19
+ }
20
+ .modal.lx-modal-shell {
21
+ --bs-modal-bg: transparent;
22
+ align-items: center;
23
+ justify-content: center;
24
+ z-index: 1505;
25
+ padding: 20px;
26
+ }
27
+ // `display: flex` ONLY while the dialog is actually open.
28
+ //
29
+ // This was `display: flex !important` on the rule above, which beat Bootstrap's
30
+ // `.modal { display: none }` — so a CLOSED <app-modal> stayed laid out as a
31
+ // full-viewport flex container and silently swallowed every click on the page behind
32
+ // it. React never hit it (react-bootstrap mounts the dialog only when open), so it
33
+ // only ever broke Razor, on any page that declared a modal.
34
+ //
35
+ // Both stacks open a modal by setting `display: block` inline (Bootstrap's JS and
36
+ // react-bootstrap alike), which is what this keys on — it covers the whole opening
37
+ // transition, not just the settled `.show` state. Specificity (0,3,0) beats `.modal`,
38
+ // so no !important is needed.
39
+ .modal.lx-modal-shell[style*="display: block"] {
40
+ display: flex;
41
+ }
42
+ // RB dialog shell: no Bootstrap max-width/margin so `.lx-modal` owns sizing.
43
+ .modal.lx-modal-shell .modal-dialog.lx-rb-dialog {
44
+ margin: 0;
45
+ max-width: none;
46
+ width: auto;
47
+ pointer-events: none;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ }
52
+ .modal.lx-modal-shell .modal-dialog.lx-rb-dialog .modal-content {
53
+ pointer-events: auto;
54
+ border: none;
55
+ // Lift off the dim backdrop: a hairline ring defines the edge (carries
56
+ // elevation in dark mode where a drop shadow barely reads) + a soft,
57
+ // deep drop shadow.
58
+ box-shadow:
59
+ 0 0 0 1px color-mix(in srgb, var(--color-text) 10%, transparent),
60
+ var(--shadow-overlay-lg);
61
+ background: var(--color-surface);
62
+ }
63
+ // `--lx-modal-max-width` is how a caller sets an explicit width. It is set on the
64
+ // `.modal` shell by BOTH surfaces and inherits down to the card, because the card is
65
+ // the element the width has to land on: `.modal-dialog` here is `width: auto`, so a
66
+ // max-width there does nothing while `.lx-modal`'s own 440px still clamps the card.
67
+ // React used to write the value straight onto `.modal-content` from JS and Razor put
68
+ // it on `.modal-dialog`, so the same `maxWidth="800"` produced two different dialogs.
69
+ .lx-modal {
70
+ background: var(--color-surface);
71
+ border-radius: var(--radius-xl);
72
+ corner-shape: squircle;
73
+ border: none;
74
+ width: 100%;
75
+ max-width: var(--lx-modal-max-width, 440px);
76
+ max-height: calc(100vh - 40px);
77
+ display: flex;
78
+ flex-direction: column;
79
+ overflow: hidden;
80
+ }
81
+ .lx-modal-wide {
82
+ max-width: var(--lx-modal-max-width, 1240px);
83
+ }
84
+
85
+ // Side drawer — the dialog docks to the right edge at 30% of the viewport
86
+ // and slides in, instead of sitting centred. Same chrome, different
87
+ // geometry.
88
+ .modal.lx-modal-shell.is-side {
89
+ align-items: stretch;
90
+ justify-content: flex-end;
91
+ padding: 0;
92
+ }
93
+ .modal.lx-modal-shell.is-side .modal-dialog.lx-rb-dialog {
94
+ height: 100%;
95
+ width: auto;
96
+ max-width: none;
97
+ align-items: stretch;
98
+ justify-content: flex-end;
99
+ }
100
+ .lx-modal-side {
101
+ width: 30vw;
102
+ min-width: 420px;
103
+ max-width: none;
104
+ max-height: 100vh;
105
+ height: 100vh;
106
+ border-radius: 0;
107
+ corner-shape: normal;
108
+ animation: lx-drawer-in 0.32s cubic-bezier(0.22, 1, 0.36, 1);
109
+ }
110
+ @media (max-width: 991.98px) {
111
+ .lx-modal-side {
112
+ width: 70vw;
113
+ }
114
+ }
115
+ @media (max-width: 575.98px) {
116
+ .lx-modal-side {
117
+ width: 100vw;
118
+ }
119
+ }
120
+ @keyframes lx-drawer-in {
121
+ from {
122
+ transform: translateX(100%);
123
+ }
124
+ to {
125
+ transform: translateX(0);
126
+ }
127
+ }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .lx-modal-side {
130
+ animation: none;
131
+ }
132
+ }
133
+ .lx-modal-header {
134
+ flex: 0 0 auto;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: space-between;
138
+ gap: 12px;
139
+ padding: 16px 20px;
140
+ border-bottom: 1px solid var(--color-border);
141
+ }
142
+ .lx-modal-body {
143
+ flex: 1 1 auto;
144
+ min-height: 0;
145
+ overflow-y: auto;
146
+ overscroll-behavior: contain;
147
+ scroll-behavior: smooth;
148
+ -webkit-overflow-scrolling: touch;
149
+ scrollbar-width: thin;
150
+ scrollbar-color: var(--color-border-strong) transparent;
151
+ padding: 20px 22px 24px;
152
+ }
153
+ .lx-modal-body::-webkit-scrollbar {
154
+ width: 8px;
155
+ }
156
+ .lx-modal-body::-webkit-scrollbar-track {
157
+ background: transparent;
158
+ }
159
+ .lx-modal-body::-webkit-scrollbar-thumb {
160
+ background: transparent;
161
+ border-radius: 999px;
162
+ border: 2px solid transparent;
163
+ background-clip: padding-box;
164
+ }
165
+ .lx-modal-body:hover::-webkit-scrollbar-thumb {
166
+ background: var(--color-border-strong);
167
+ background-clip: padding-box;
168
+ }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ .lx-modal-body {
171
+ scroll-behavior: auto;
172
+ }
173
+ }
174
+ // Modal heading group: optional contextual (Bulk) icon tile + title/subtitle.
175
+ .lx-modal-heading {
176
+ display: flex;
177
+ align-items: center;
178
+ gap: 10px;
179
+ min-width: 0;
180
+ }
181
+ .lx-modal-headtext {
182
+ min-width: 0;
183
+ }
184
+ .lx-modal-headicon {
185
+ display: inline-flex;
186
+ align-items: center;
187
+ justify-content: center;
188
+ width: 34px;
189
+ height: 34px;
190
+ border-radius: var(--radius);
191
+ corner-shape: squircle;
192
+ flex: none;
193
+ background: var(--color-primary-soft);
194
+ color: var(--color-primary);
195
+ }
196
+
197
+ // Break long unbroken names so they wrap within the heading instead of
198
+ // running under the close button.
199
+ .lx-modal-title {
200
+ font-size: 20px;
201
+ font-weight: 600;
202
+ color: var(--color-text);
203
+ overflow-wrap: anywhere;
204
+ }
205
+ .lx-modal-subtitle {
206
+ margin-top: 1px;
207
+ font-size: 12px;
208
+ font-weight: 400;
209
+ color: var(--color-text-muted);
210
+ overflow-wrap: anywhere;
211
+ }
212
+ .lx-form-section {
213
+ margin-top: 24px;
214
+ }
215
+ .lx-form-section:first-child {
216
+ margin-top: 0;
217
+ }
218
+ // Stretch controls to fill the column even though .field aligns to
219
+ // flex-start.
220
+ .lx-modal-body .field > .input,
221
+ .lx-modal-body .field > .search-select,
222
+ .lx-modal-body .field > .input-date-wrap,
223
+ .lx-modal-body .field > .datepicker,
224
+ .lx-modal-body .field > .phone-input-wrap,
225
+ .lx-modal-body .field > .field-error {
226
+ align-self: stretch;
227
+ }
228
+ .lx-form-section-head {
229
+ margin-bottom: 16px;
230
+ }
231
+ .lx-form-section-title {
232
+ font-size: 16px;
233
+ font-weight: 600;
234
+ text-transform: uppercase;
235
+ letter-spacing: 0.04em;
236
+ color: var(--color-text-subtle);
237
+ }
238
+ .lx-modal-footer {
239
+ flex: 0 0 auto;
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: flex-end;
243
+ gap: 10px;
244
+ padding: 16px 22px;
245
+ border-top: 1px solid var(--color-border);
246
+ }
247
+
248
+ // Modal close — a squircle at rest that morphs into a circle on hover while
249
+ // taking a danger tint, so dismissing reads as the destructive-ish gesture
250
+ // it is. Slower on the way in than the way out.
251
+ .lx-modal-close {
252
+ margin-left: auto;
253
+ display: grid;
254
+ place-items: center;
255
+ flex: none;
256
+ width: 36px;
257
+ height: 36px;
258
+ padding: 0;
259
+ border: none;
260
+ cursor: pointer;
261
+ line-height: 1;
262
+ background: var(--color-bg-subtle);
263
+ color: var(--color-text-muted);
264
+ border-radius: 999px;
265
+ corner-shape: squircle;
266
+ transition:
267
+ background 180ms cubic-bezier(0.4, 0, 0.2, 1),
268
+ color 180ms cubic-bezier(0.4, 0, 0.2, 1),
269
+ corner-shape 180ms cubic-bezier(0.4, 0, 0.2, 1);
270
+ }
271
+ .lx-modal-close:hover {
272
+ background: var(--color-danger-soft);
273
+ color: var(--color-danger);
274
+ corner-shape: round;
275
+ transition:
276
+ background 280ms cubic-bezier(0.16, 1, 0.3, 1),
277
+ color 280ms cubic-bezier(0.16, 1, 0.3, 1),
278
+ corner-shape 280ms cubic-bezier(0.16, 1, 0.3, 1);
279
+ }
280
+ .lx-modal-close:focus-visible {
281
+ outline: 2px solid var(--color-primary);
282
+ outline-offset: 2px;
283
+ }
284
+ // The shape morph is decorative; hold the colour cue for reduced-motion
285
+ // users.
286
+ @media (prefers-reduced-motion: reduce) {
287
+ .lx-modal-close,
288
+ .lx-modal-close:hover {
289
+ corner-shape: squircle;
290
+ transition:
291
+ background 1ms linear,
292
+ color 1ms linear;
293
+ }
294
+ }
295
+
296
+ // ── Confirm dialog ──
297
+ // Confirmation dialog — centred status icon + title + message, two equal
298
+ // side-by-side CTAs. Runs headerless (Modal hideHeader), so the body
299
+ // carries the spacing and the footer drops its divider.
300
+ // The confirm card's width belongs to the theme, not to every caller passing
301
+ // maxWidth={440}. Both surfaces used to declare it and Razor's landed on the wrong
302
+ // element, so the dialogs were different widths.
303
+ .lx-confirm {
304
+ max-width: var(--lx-modal-max-width, 440px);
305
+ }
306
+ .lx-confirm-content {
307
+ display: flex;
308
+ flex-direction: column;
309
+ align-items: center;
310
+ text-align: center;
311
+ padding: 16px 12px 6px;
312
+ }
313
+ .lx-confirm-icon {
314
+ line-height: 0;
315
+ margin-bottom: 22px;
316
+ }
317
+ .lx-confirm-title {
318
+ margin: 0 0 10px;
319
+ font-size: 22px;
320
+ font-weight: 700;
321
+ letter-spacing: -0.01em;
322
+ color: var(--color-text);
323
+ }
324
+ .lx-confirm-msg {
325
+ margin: 0;
326
+ font-size: 15px;
327
+ line-height: 1.55;
328
+ color: var(--color-text-muted);
329
+ max-width: 40ch;
330
+ }
331
+ .lx-confirm-actions {
332
+ display: flex;
333
+ gap: 12px;
334
+ width: 100%;
335
+ }
336
+ .lx-modal-footer:has(.lx-confirm-actions) {
337
+ border-top: none;
338
+ padding-top: 0;
339
+ }
340
+ .lx-confirm-actions .btn {
341
+ flex: 1;
342
+ min-height: var(--wui-control-height);
343
+ height: var(--wui-control-height);
344
+ border-radius: var(--radius);
345
+ corner-shape: squircle;
346
+ padding-left: 16px;
347
+ padding-right: 16px;
348
+ font-size: 14px;
349
+ font-weight: 500;
350
+ }