@oleksandr-94/aura-css 0.1.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.
- package/LICENSE +21 -0
- package/README.md +593 -0
- package/dist/aura-flat.css +1611 -0
- package/dist/aura-flat.min.css +1 -0
- package/dist/aura-neu.css +1622 -0
- package/dist/aura-neu.min.css +1 -0
- package/dist/aura.css +1634 -0
- package/dist/aura.min.css +1 -0
- package/dist/interactions.d.ts +57 -0
- package/dist/interactions.global.min.js +1 -0
- package/dist/interactions.min.mjs +1 -0
- package/dist/interactions.mjs +208 -0
- package/package.json +72 -0
- package/src/_config.scss +72 -0
- package/src/_functions.scss +23 -0
- package/src/_layers.scss +4 -0
- package/src/_surface.scss +49 -0
- package/src/_tokens.scss +65 -0
- package/src/base/_a11y.scss +31 -0
- package/src/base/_reset.scss +7 -0
- package/src/components/_accordion.scss +62 -0
- package/src/components/_alert.scss +43 -0
- package/src/components/_avatar.scss +50 -0
- package/src/components/_badge.scss +61 -0
- package/src/components/_button.scss +79 -0
- package/src/components/_card.scss +58 -0
- package/src/components/_checkbox.scss +51 -0
- package/src/components/_dropdown.scss +73 -0
- package/src/components/_file.scss +42 -0
- package/src/components/_group.scss +51 -0
- package/src/components/_input.scss +85 -0
- package/src/components/_modal.scss +43 -0
- package/src/components/_pagination.scss +87 -0
- package/src/components/_progress.scss +44 -0
- package/src/components/_radio.scss +42 -0
- package/src/components/_range.scss +27 -0
- package/src/components/_rating.scss +43 -0
- package/src/components/_segmented.scss +60 -0
- package/src/components/_switch.scss +79 -0
- package/src/components/_table.scss +38 -0
- package/src/components/_tabs.scss +150 -0
- package/src/components/_toast.scss +59 -0
- package/src/components/_tooltip.scss +60 -0
- package/src/index.scss +32 -0
- package/src/js/global.js +7 -0
- package/src/js/interactions.auto.js +5 -0
- package/src/js/interactions.js +192 -0
- package/src/skins/_flat.scss +29 -0
- package/src/skins/_glass.scss +40 -0
- package/src/skins/_neu.scss +49 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aura — Tabs (segmented)
|
|
3
|
+
// Container is a skin-aware well; the active tab is a skin-aware
|
|
4
|
+
// filled control. Content switching is left to the app.
|
|
5
|
+
// ============================================================
|
|
6
|
+
@use "../config" as cfg;
|
|
7
|
+
@use "../surface" as s;
|
|
8
|
+
$p: cfg.$prefix;
|
|
9
|
+
|
|
10
|
+
@layer components {
|
|
11
|
+
.#{$p}tabs {
|
|
12
|
+
display: inline-flex;
|
|
13
|
+
gap: 4px;
|
|
14
|
+
padding: 4px;
|
|
15
|
+
border-radius: var(--radius-md);
|
|
16
|
+
@include s.field;
|
|
17
|
+
}
|
|
18
|
+
.#{$p}tabs--block { display: flex; width: 100%; }
|
|
19
|
+
.#{$p}tabs--block .#{$p}tab { flex: 1; }
|
|
20
|
+
|
|
21
|
+
.#{$p}tab {
|
|
22
|
+
font: inherit;
|
|
23
|
+
font-weight: 700;
|
|
24
|
+
font-size: var(--fs-sm);
|
|
25
|
+
padding: 8px 14px;
|
|
26
|
+
border: none;
|
|
27
|
+
border-radius: var(--radius-sm);
|
|
28
|
+
background: transparent;
|
|
29
|
+
color: var(--on-surface-muted);
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
white-space: nowrap;
|
|
32
|
+
transition: color .15s ease;
|
|
33
|
+
|
|
34
|
+
&:hover:not(.#{$p}tab--active) { color: var(--on-surface); }
|
|
35
|
+
&:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.#{$p}tab--active {
|
|
39
|
+
--_bg: var(--primary);
|
|
40
|
+
--_fg: var(--on-primary);
|
|
41
|
+
@include s.control;
|
|
42
|
+
color: var(--_fg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// panel shown/hidden by the JS (or your framework) via the `hidden` attribute
|
|
46
|
+
.#{$p}tab-panel { margin-top: var(--space-4); }
|
|
47
|
+
|
|
48
|
+
// ---- underline variant ----
|
|
49
|
+
.#{$p}tabs--underline {
|
|
50
|
+
gap: 4px;
|
|
51
|
+
padding: 0;
|
|
52
|
+
background: none;
|
|
53
|
+
border: none;
|
|
54
|
+
border-bottom: 1px solid var(--outline);
|
|
55
|
+
border-radius: 0;
|
|
56
|
+
box-shadow: none;
|
|
57
|
+
-webkit-backdrop-filter: none;
|
|
58
|
+
backdrop-filter: none;
|
|
59
|
+
|
|
60
|
+
.#{$p}tab {
|
|
61
|
+
border-radius: 0;
|
|
62
|
+
padding: 10px 14px;
|
|
63
|
+
border-bottom: 2px solid transparent;
|
|
64
|
+
margin-bottom: -1px;
|
|
65
|
+
}
|
|
66
|
+
.#{$p}tab--active {
|
|
67
|
+
background: none;
|
|
68
|
+
box-shadow: none;
|
|
69
|
+
color: var(--primary);
|
|
70
|
+
border-bottom-color: var(--primary);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- lift variant (raised "folder" tab that merges into its panel) ----
|
|
75
|
+
// Pair with .tab-panels (a surface-1 box). The active tab overlaps the
|
|
76
|
+
// panel's top border and shares its background → one connected surface.
|
|
77
|
+
.#{$p}tabs--lift {
|
|
78
|
+
gap: 4px;
|
|
79
|
+
padding: 0;
|
|
80
|
+
background: none;
|
|
81
|
+
border: none;
|
|
82
|
+
border-radius: 0;
|
|
83
|
+
box-shadow: none;
|
|
84
|
+
-webkit-backdrop-filter: none;
|
|
85
|
+
backdrop-filter: none;
|
|
86
|
+
|
|
87
|
+
.#{$p}tab {
|
|
88
|
+
position: relative;
|
|
89
|
+
z-index: 0;
|
|
90
|
+
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
91
|
+
padding: 10px 16px;
|
|
92
|
+
border: 1px solid transparent;
|
|
93
|
+
border-bottom: none;
|
|
94
|
+
margin-bottom: -1px; // overlap the panel's top border
|
|
95
|
+
}
|
|
96
|
+
.#{$p}tab--active {
|
|
97
|
+
z-index: 1; // sit above the panel border → merge
|
|
98
|
+
background: var(--surface-1);
|
|
99
|
+
box-shadow: none;
|
|
100
|
+
color: var(--on-surface);
|
|
101
|
+
border-color: var(--outline);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Panel container — a surface card that works with any tab variant.
|
|
106
|
+
// Default: a standalone rounded card (a gap below pill/underline tabs).
|
|
107
|
+
// Default: a plain content area that flows under pill / underline tabs
|
|
108
|
+
// (no second box). After lift tabs it becomes a connected surface card.
|
|
109
|
+
.#{$p}tab-panels {
|
|
110
|
+
padding-top: var(--space-4);
|
|
111
|
+
.#{$p}tab-panel { margin-top: 0; }
|
|
112
|
+
}
|
|
113
|
+
.#{$p}tabs--lift + .#{$p}tab-panels {
|
|
114
|
+
padding: var(--space-6);
|
|
115
|
+
background: var(--surface-1);
|
|
116
|
+
border: 1px solid var(--outline);
|
|
117
|
+
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- tabbed card: tabs are the header, panels the body — one card,
|
|
121
|
+
// no gap. Works with pill / underline tabs inside. ----
|
|
122
|
+
.#{$p}tab-card {
|
|
123
|
+
background: var(--surface-1);
|
|
124
|
+
border: 1px solid var(--outline);
|
|
125
|
+
border-radius: var(--radius-lg);
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
|
|
128
|
+
> .#{$p}tabs {
|
|
129
|
+
display: flex; // full width → divider spans the whole card
|
|
130
|
+
background: none;
|
|
131
|
+
border: none;
|
|
132
|
+
border-bottom: 1px solid var(--outline);
|
|
133
|
+
border-radius: 0;
|
|
134
|
+
box-shadow: none;
|
|
135
|
+
-webkit-backdrop-filter: none;
|
|
136
|
+
backdrop-filter: none;
|
|
137
|
+
padding: var(--space-2);
|
|
138
|
+
}
|
|
139
|
+
// underline tabs: drop bottom padding so the underline sits on the divider
|
|
140
|
+
> .#{$p}tabs--underline { padding: var(--space-1) var(--space-2) 0; }
|
|
141
|
+
> .#{$p}tab-panels {
|
|
142
|
+
margin-top: 0;
|
|
143
|
+
padding: var(--space-6);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- sizes (compose with any variant) ----
|
|
148
|
+
.#{$p}tabs--sm .#{$p}tab { padding: 5px 10px; font-size: var(--fs-xs); }
|
|
149
|
+
.#{$p}tabs--lg .#{$p}tab { padding: 11px 18px; font-size: var(--fs-md); }
|
|
150
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aura — Toast
|
|
3
|
+
// Transient notification. Show it via the JS helper `toast(msg,
|
|
4
|
+
// {type})`, or render the markup yourself and flip data-state.
|
|
5
|
+
// ============================================================
|
|
6
|
+
@use "../config" as cfg;
|
|
7
|
+
$p: cfg.$prefix;
|
|
8
|
+
|
|
9
|
+
@layer components {
|
|
10
|
+
.#{$p}toast-container {
|
|
11
|
+
position: fixed;
|
|
12
|
+
top: 20px;
|
|
13
|
+
right: 20px;
|
|
14
|
+
z-index: var(--z-toast);
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 10px;
|
|
18
|
+
width: min(360px, calc(100vw - 40px));
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
}
|
|
21
|
+
.#{$p}toast-container--bottom { top: auto; bottom: 20px; }
|
|
22
|
+
|
|
23
|
+
.#{$p}toast {
|
|
24
|
+
--_c: var(--primary);
|
|
25
|
+
pointer-events: auto;
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: flex-start;
|
|
28
|
+
gap: 12px;
|
|
29
|
+
padding: 12px 14px;
|
|
30
|
+
border-radius: var(--radius-md);
|
|
31
|
+
background: var(--surface-1);
|
|
32
|
+
border: 1px solid var(--outline);
|
|
33
|
+
border-left: 3px solid var(--_c);
|
|
34
|
+
box-shadow: var(--shadow-2);
|
|
35
|
+
font-size: var(--fs-sm);
|
|
36
|
+
opacity: 0;
|
|
37
|
+
transform: translateX(12px);
|
|
38
|
+
transition: opacity .2s ease, transform .2s ease;
|
|
39
|
+
}
|
|
40
|
+
.#{$p}toast[data-state="open"] { opacity: 1; transform: none; }
|
|
41
|
+
|
|
42
|
+
.#{$p}toast--success { --_c: var(--success); }
|
|
43
|
+
.#{$p}toast--warning { --_c: var(--warning); }
|
|
44
|
+
.#{$p}toast--error { --_c: var(--error); }
|
|
45
|
+
.#{$p}toast--info { --_c: var(--info); }
|
|
46
|
+
|
|
47
|
+
.#{$p}toast__body { flex: 1; color: var(--on-surface); line-height: 1.4; }
|
|
48
|
+
.#{$p}toast__close {
|
|
49
|
+
flex: none;
|
|
50
|
+
border: none;
|
|
51
|
+
background: none;
|
|
52
|
+
color: var(--on-surface-muted);
|
|
53
|
+
font-size: 18px;
|
|
54
|
+
line-height: 1;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
padding: 0 2px;
|
|
57
|
+
&:hover { color: var(--on-surface); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aura — Tooltip
|
|
3
|
+
// Pure-CSS, shown on hover/focus. Inverted vs. the theme
|
|
4
|
+
// (light bubble on dark, dark bubble on light) via tip tokens.
|
|
5
|
+
// ============================================================
|
|
6
|
+
@use "../config" as cfg;
|
|
7
|
+
$p: cfg.$prefix;
|
|
8
|
+
|
|
9
|
+
@layer components {
|
|
10
|
+
.#{$p}tooltip {
|
|
11
|
+
position: relative;
|
|
12
|
+
display: inline-flex;
|
|
13
|
+
}
|
|
14
|
+
.#{$p}tooltip__text {
|
|
15
|
+
visibility: hidden;
|
|
16
|
+
opacity: 0;
|
|
17
|
+
position: absolute;
|
|
18
|
+
bottom: calc(100% + 8px);
|
|
19
|
+
left: 50%;
|
|
20
|
+
transform: translateX(-50%);
|
|
21
|
+
white-space: nowrap;
|
|
22
|
+
background: var(--tip-bg);
|
|
23
|
+
color: var(--tip-text);
|
|
24
|
+
font-size: var(--fs-xs);
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
padding: 6px 10px;
|
|
27
|
+
border-radius: var(--radius-sm);
|
|
28
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.25);
|
|
29
|
+
z-index: var(--z-dropdown);
|
|
30
|
+
transition: opacity .15s ease, visibility .15s ease;
|
|
31
|
+
}
|
|
32
|
+
// caret
|
|
33
|
+
.#{$p}tooltip__text::after {
|
|
34
|
+
content: "";
|
|
35
|
+
position: absolute;
|
|
36
|
+
top: 100%;
|
|
37
|
+
left: 50%;
|
|
38
|
+
transform: translateX(-50%);
|
|
39
|
+
border: 5px solid transparent;
|
|
40
|
+
border-top-color: var(--tip-bg);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.#{$p}tooltip:hover .#{$p}tooltip__text,
|
|
44
|
+
.#{$p}tooltip:focus-within .#{$p}tooltip__text {
|
|
45
|
+
visibility: visible;
|
|
46
|
+
opacity: 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- placement: bottom ----
|
|
50
|
+
.#{$p}tooltip--bottom .#{$p}tooltip__text {
|
|
51
|
+
bottom: auto;
|
|
52
|
+
top: calc(100% + 8px);
|
|
53
|
+
}
|
|
54
|
+
.#{$p}tooltip--bottom .#{$p}tooltip__text::after {
|
|
55
|
+
top: auto;
|
|
56
|
+
bottom: 100%;
|
|
57
|
+
border-top-color: transparent;
|
|
58
|
+
border-bottom-color: var(--tip-bg);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.scss
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aura — entry point
|
|
3
|
+
// Cascade-layer order (declared in _layers): host app styles
|
|
4
|
+
// (unlayered) always win over Aura.
|
|
5
|
+
// ============================================================
|
|
6
|
+
@use "layers";
|
|
7
|
+
@use "tokens";
|
|
8
|
+
@use "base/reset";
|
|
9
|
+
@use "base/a11y";
|
|
10
|
+
@use "components/button";
|
|
11
|
+
@use "components/card";
|
|
12
|
+
@use "components/input";
|
|
13
|
+
@use "components/badge";
|
|
14
|
+
@use "components/alert";
|
|
15
|
+
@use "components/switch";
|
|
16
|
+
@use "components/checkbox";
|
|
17
|
+
@use "components/radio";
|
|
18
|
+
@use "components/range";
|
|
19
|
+
@use "components/file";
|
|
20
|
+
@use "components/rating";
|
|
21
|
+
@use "components/tabs";
|
|
22
|
+
@use "components/table";
|
|
23
|
+
@use "components/group";
|
|
24
|
+
@use "components/segmented";
|
|
25
|
+
@use "components/progress";
|
|
26
|
+
@use "components/tooltip";
|
|
27
|
+
@use "components/modal";
|
|
28
|
+
@use "components/dropdown";
|
|
29
|
+
@use "components/avatar";
|
|
30
|
+
@use "components/accordion";
|
|
31
|
+
@use "components/toast";
|
|
32
|
+
@use "components/pagination";
|
package/src/js/global.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Global (IIFE) build entry: exposes the API on a window global and
|
|
2
|
+
// auto-wires triggers. For <script src> without type="module".
|
|
3
|
+
// The global name is set at build time (--global-name), so it is not
|
|
4
|
+
// hard-tied to the library name.
|
|
5
|
+
export * from './interactions.js';
|
|
6
|
+
import { mount } from './interactions.js';
|
|
7
|
+
mount();
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aura interactions — framework-agnostic behaviour layer.
|
|
3
|
+
//
|
|
4
|
+
// State lives in the DOM: [data-state="open|closed"]. This module
|
|
5
|
+
// only flips that attribute and adds a11y niceties. It is OPTIONAL:
|
|
6
|
+
// • Vanilla: load interactions.auto.js (auto-wires data-* triggers).
|
|
7
|
+
// • React/Vue/etc: bind data-state yourself; ignore this, or import
|
|
8
|
+
// open/close/trapFocus as pure helpers.
|
|
9
|
+
//
|
|
10
|
+
// Nothing here is tied to the library name — see configure({prefix}).
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
let PFX = '';
|
|
14
|
+
const A = (name) => `data-${PFX}${name}`; // trigger attribute name
|
|
15
|
+
const C = (name) => `${PFX}${name}`; // class name (matches SCSS $prefix)
|
|
16
|
+
|
|
17
|
+
/** Optional: namespace the trigger attributes, e.g. prefix:'ui' → data-ui-open. */
|
|
18
|
+
export function configure(opts = {}) {
|
|
19
|
+
if (opts.prefix != null) PFX = opts.prefix ? opts.prefix + '-' : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FOCUSABLE =
|
|
23
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
24
|
+
const cleanups = new WeakMap(); // element -> teardown fn
|
|
25
|
+
let scrollLocks = 0;
|
|
26
|
+
|
|
27
|
+
const resolve = (t) => (!t ? null : typeof t === 'string' ? document.querySelector(t) : t);
|
|
28
|
+
const stateEl = (el) => (el ? el.closest('[data-state]') : null);
|
|
29
|
+
|
|
30
|
+
export function lockScroll() {
|
|
31
|
+
if (scrollLocks++ === 0) document.documentElement.style.overflow = 'hidden';
|
|
32
|
+
}
|
|
33
|
+
export function unlockScroll() {
|
|
34
|
+
if (--scrollLocks <= 0) { scrollLocks = 0; document.documentElement.style.overflow = ''; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function trapFocus(el) {
|
|
38
|
+
const nodes = el.querySelectorAll(FOCUSABLE);
|
|
39
|
+
(nodes[0] || el).focus?.();
|
|
40
|
+
const onKey = (e) => {
|
|
41
|
+
if (e.key !== 'Tab' || !nodes.length) return;
|
|
42
|
+
const first = nodes[0], last = nodes[nodes.length - 1];
|
|
43
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
44
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
45
|
+
};
|
|
46
|
+
el.addEventListener('keydown', onKey);
|
|
47
|
+
return () => el.removeEventListener('keydown', onKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function activate(el) {
|
|
51
|
+
const teardown = [];
|
|
52
|
+
const onEsc = (e) => { if (e.key === 'Escape') close(el); };
|
|
53
|
+
document.addEventListener('keydown', onEsc);
|
|
54
|
+
teardown.push(() => document.removeEventListener('keydown', onEsc));
|
|
55
|
+
|
|
56
|
+
if (el.hasAttribute('data-overlay')) {
|
|
57
|
+
// modal: backdrop click, scroll lock, focus trap
|
|
58
|
+
const onClick = (e) => { if (e.target === el) close(el); };
|
|
59
|
+
el.addEventListener('click', onClick);
|
|
60
|
+
teardown.push(() => el.removeEventListener('click', onClick));
|
|
61
|
+
lockScroll();
|
|
62
|
+
teardown.push(unlockScroll);
|
|
63
|
+
const prev = document.activeElement;
|
|
64
|
+
teardown.push(trapFocus(el));
|
|
65
|
+
teardown.push(() => prev && prev.focus && prev.focus());
|
|
66
|
+
} else {
|
|
67
|
+
// popover/dropdown: outside click closes; reflect aria-expanded;
|
|
68
|
+
// arrow-key navigation over the menu items.
|
|
69
|
+
const trg = el.querySelector('[' + A('toggle') + ']');
|
|
70
|
+
if (trg) {
|
|
71
|
+
trg.setAttribute('aria-expanded', 'true');
|
|
72
|
+
teardown.push(() => { trg.setAttribute('aria-expanded', 'false'); trg.focus(); });
|
|
73
|
+
}
|
|
74
|
+
const items = () => [...el.querySelectorAll(FOCUSABLE)].filter((n) => !n.hasAttribute(A('toggle')));
|
|
75
|
+
const list = items();
|
|
76
|
+
if (list.length) setTimeout(() => list[0].focus(), 0);
|
|
77
|
+
const onNav = (e) => {
|
|
78
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
79
|
+
const l = items(); if (!l.length) return;
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
const i = l.indexOf(document.activeElement);
|
|
82
|
+
let n;
|
|
83
|
+
if (e.key === 'Home') n = 0;
|
|
84
|
+
else if (e.key === 'End') n = l.length - 1;
|
|
85
|
+
else if (e.key === 'ArrowDown') n = i < 0 ? 0 : (i + 1) % l.length;
|
|
86
|
+
else n = i <= 0 ? l.length - 1 : i - 1;
|
|
87
|
+
l[n].focus();
|
|
88
|
+
};
|
|
89
|
+
el.addEventListener('keydown', onNav);
|
|
90
|
+
teardown.push(() => el.removeEventListener('keydown', onNav));
|
|
91
|
+
const onDoc = (e) => { if (!el.contains(e.target)) close(el); };
|
|
92
|
+
setTimeout(() => document.addEventListener('click', onDoc), 0);
|
|
93
|
+
teardown.push(() => document.removeEventListener('click', onDoc));
|
|
94
|
+
}
|
|
95
|
+
cleanups.set(el, () => teardown.forEach((fn) => fn()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function open(target) {
|
|
99
|
+
const el = resolve(target);
|
|
100
|
+
if (!el || el.getAttribute('data-state') === 'open') return;
|
|
101
|
+
el.setAttribute('data-state', 'open');
|
|
102
|
+
el.dispatchEvent(new CustomEvent('ui:open', { bubbles: true }));
|
|
103
|
+
activate(el);
|
|
104
|
+
}
|
|
105
|
+
export function close(target) {
|
|
106
|
+
const el = resolve(target);
|
|
107
|
+
if (!el || el.getAttribute('data-state') !== 'open') return;
|
|
108
|
+
el.setAttribute('data-state', 'closed');
|
|
109
|
+
el.dispatchEvent(new CustomEvent('ui:close', { bubbles: true }));
|
|
110
|
+
const t = cleanups.get(el);
|
|
111
|
+
if (t) { t(); cleanups.delete(el); }
|
|
112
|
+
}
|
|
113
|
+
export function toggle(target) {
|
|
114
|
+
const el = resolve(target);
|
|
115
|
+
if (!el) return;
|
|
116
|
+
el.getAttribute('data-state') === 'open' ? close(el) : open(el);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Imperatively show a toast. Returns a dismiss() fn. */
|
|
120
|
+
export function toast(message, opts = {}) {
|
|
121
|
+
const { type = '', timeout = 4000, position = 'top-right' } = opts;
|
|
122
|
+
let c = document.querySelector('[data-toast-container]');
|
|
123
|
+
if (!c) {
|
|
124
|
+
c = document.createElement('div');
|
|
125
|
+
c.className = C('toast-container') + (position.includes('bottom') ? ' ' + C('toast-container--bottom') : '');
|
|
126
|
+
c.setAttribute('data-toast-container', '');
|
|
127
|
+
c.setAttribute('role', 'region');
|
|
128
|
+
c.setAttribute('aria-live', 'polite');
|
|
129
|
+
c.setAttribute('aria-label', 'Notifications');
|
|
130
|
+
document.body.appendChild(c);
|
|
131
|
+
}
|
|
132
|
+
const el = document.createElement('div');
|
|
133
|
+
el.className = C('toast') + (type ? ' ' + C('toast--' + type) : '');
|
|
134
|
+
el.setAttribute('data-state', 'closed');
|
|
135
|
+
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
|
136
|
+
el.innerHTML =
|
|
137
|
+
'<div class="' + C('toast__body') + '">' + message + '</div>' +
|
|
138
|
+
'<button class="' + C('toast__close') + '" aria-label="Dismiss">×</button>';
|
|
139
|
+
c.appendChild(el);
|
|
140
|
+
requestAnimationFrame(() => el.setAttribute('data-state', 'open'));
|
|
141
|
+
const dismiss = () => { el.setAttribute('data-state', 'closed'); setTimeout(() => el.remove(), 250); };
|
|
142
|
+
el.querySelector('.' + C('toast__close')).addEventListener('click', dismiss);
|
|
143
|
+
if (timeout) setTimeout(dismiss, timeout);
|
|
144
|
+
return dismiss;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Activate a tab: mark it active and show its panel (data-tab="#id"), hide siblings. */
|
|
148
|
+
export function activateTab(tab) {
|
|
149
|
+
const bar = tab.closest('.' + C('tabs'));
|
|
150
|
+
if (!bar) return;
|
|
151
|
+
bar.querySelectorAll('.' + C('tab')).forEach((t) => {
|
|
152
|
+
const on = t === tab;
|
|
153
|
+
t.classList.toggle(C('tab--active'), on);
|
|
154
|
+
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
155
|
+
t.tabIndex = on ? 0 : -1;
|
|
156
|
+
const sel = t.getAttribute(A('tab'));
|
|
157
|
+
if (sel) { const p = document.querySelector(sel); if (p) p.hidden = !on; }
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let mounted = false;
|
|
162
|
+
/** Install delegated listeners for data-open / data-close / data-toggle and tabs. */
|
|
163
|
+
export function mount(root = document) {
|
|
164
|
+
if (mounted) return; mounted = true;
|
|
165
|
+
root.addEventListener('click', (e) => {
|
|
166
|
+
const tabEl = e.target.closest('.' + C('tab'));
|
|
167
|
+
if (tabEl && tabEl.hasAttribute(A('tab'))) return activateTab(tabEl);
|
|
168
|
+
const o = e.target.closest('[' + A('open') + ']');
|
|
169
|
+
if (o) { e.preventDefault(); return open(o.getAttribute(A('open'))); }
|
|
170
|
+
const c = e.target.closest('[' + A('close') + ']');
|
|
171
|
+
if (c) { const t = c.getAttribute(A('close')); return close(t || stateEl(c)); }
|
|
172
|
+
const t = e.target.closest('[' + A('toggle') + ']');
|
|
173
|
+
if (t) { e.preventDefault(); const v = t.getAttribute(A('toggle')); return toggle(v || stateEl(t)); }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Arrow-key navigation across a .tabs bar (moves focus + activates).
|
|
177
|
+
root.addEventListener('keydown', (e) => {
|
|
178
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
|
|
179
|
+
const tab = e.target.closest('.' + C('tab'));
|
|
180
|
+
if (!tab) return;
|
|
181
|
+
const bar = tab.closest('.' + C('tabs'));
|
|
182
|
+
if (!bar) return;
|
|
183
|
+
const tabs = [...bar.querySelectorAll('.' + C('tab'))];
|
|
184
|
+
const i = tabs.indexOf(tab);
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
const n = e.key === 'ArrowRight' ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
|
|
187
|
+
tabs[n].focus();
|
|
188
|
+
if (tabs[n].hasAttribute(A('tab'))) activateTab(tabs[n]);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default { configure, mount, open, close, toggle, toast, activateTab, trapFocus, lockScroll, unlockScroll };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Skin: flat — solid fills, crisp borders, no blur, no shadow.
|
|
3
|
+
// ============================================================
|
|
4
|
+
@mixin surface($level: 1) {
|
|
5
|
+
background: var(--surface-#{$level});
|
|
6
|
+
border: 1px solid var(--outline);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
@mixin field {
|
|
10
|
+
background: var(--surface-1);
|
|
11
|
+
border: 1px solid var(--outline-strong);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Filled control — plain solid fill.
|
|
15
|
+
@mixin control {
|
|
16
|
+
background: var(--_bg);
|
|
17
|
+
color: var(--_fg);
|
|
18
|
+
border: 1px solid transparent;
|
|
19
|
+
box-shadow: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@mixin control-active {
|
|
23
|
+
transform: translateY(1px);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Small tinted pill (badge) — flat: colour tint only.
|
|
27
|
+
@mixin chip {
|
|
28
|
+
box-shadow: none;
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Skin: glass — frosted, translucent, blurred.
|
|
3
|
+
// The frosted film is a LIGHT translucent layer (not the surface
|
|
4
|
+
// colour) so a colourful backdrop shows through, blurred — that's
|
|
5
|
+
// what reads as real glass.
|
|
6
|
+
// ============================================================
|
|
7
|
+
@mixin surface($level: 1) {
|
|
8
|
+
@if $level == 2 { background: var(--glass-film-2); }
|
|
9
|
+
@else { background: var(--glass-film); }
|
|
10
|
+
-webkit-backdrop-filter: blur(var(--blur)) saturate(180%);
|
|
11
|
+
backdrop-filter: blur(var(--blur)) saturate(180%);
|
|
12
|
+
border: 1px solid var(--outline);
|
|
13
|
+
box-shadow: var(--shadow-#{$level}), inset 0 1px 0 rgba(255,255,255,.12);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@mixin field {
|
|
17
|
+
background: var(--glass-film);
|
|
18
|
+
-webkit-backdrop-filter: blur(var(--blur));
|
|
19
|
+
backdrop-filter: blur(var(--blur));
|
|
20
|
+
border: 1px solid var(--outline-strong);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Filled control — solid colour with a soft glow and a glassy top gloss.
|
|
24
|
+
@mixin control {
|
|
25
|
+
background: var(--_bg);
|
|
26
|
+
color: var(--_fg);
|
|
27
|
+
border: 1px solid transparent;
|
|
28
|
+
box-shadow:
|
|
29
|
+
0 6px 18px color-mix(in srgb, var(--_shadow-color, var(--_bg)) 40%, transparent),
|
|
30
|
+
inset 0 1px 0 rgba(255,255,255,.28);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@mixin control-active {
|
|
34
|
+
transform: translateY(1px);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Small tinted pill (badge) — glass: tint + faint top gloss.
|
|
38
|
+
@mixin chip {
|
|
39
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,.22);
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Skin: neu — soft neumorphism, extruded / inset shadows.
|
|
3
|
+
// Works best when the page background matches --surface.
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
// Panels / cards — extruded from the surface.
|
|
7
|
+
@mixin surface($level: 1) {
|
|
8
|
+
background: var(--surface);
|
|
9
|
+
border: none;
|
|
10
|
+
box-shadow:
|
|
11
|
+
8px 8px 20px color-mix(in srgb, var(--surface) 45%, #000),
|
|
12
|
+
-8px -8px 20px color-mix(in srgb, var(--surface) 88%, #fff);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Control wells (inputs, checkboxes, switch tracks) — pressed into
|
|
16
|
+
// the surface. Sharp, small inset so it reads on tiny controls too.
|
|
17
|
+
@mixin field {
|
|
18
|
+
background: var(--surface);
|
|
19
|
+
border: none;
|
|
20
|
+
box-shadow:
|
|
21
|
+
inset 2px 2px 4px color-mix(in srgb, var(--surface) 40%, #000),
|
|
22
|
+
inset -2px -2px 4px color-mix(in srgb, var(--surface) 92%, #fff);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Filled controls (buttons) — extruded, coloured; shadows derived
|
|
26
|
+
// from the control's own colour so any variant stays neumorphic.
|
|
27
|
+
@mixin control {
|
|
28
|
+
background: var(--_bg);
|
|
29
|
+
color: var(--_fg);
|
|
30
|
+
border: none;
|
|
31
|
+
box-shadow:
|
|
32
|
+
3px 3px 7px color-mix(in srgb, var(--_shadow-color, var(--_bg)) 55%, #000),
|
|
33
|
+
-3px -3px 7px color-mix(in srgb, var(--_shadow-color, var(--_bg)) 82%, #fff);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Pressed state — inset.
|
|
37
|
+
@mixin control-active {
|
|
38
|
+
transform: none;
|
|
39
|
+
box-shadow:
|
|
40
|
+
inset 2px 2px 5px color-mix(in srgb, var(--_shadow-color, var(--_bg)) 55%, #000),
|
|
41
|
+
inset -2px -2px 5px color-mix(in srgb, var(--_shadow-color, var(--_bg)) 82%, #fff);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Small tinted pill (badge) — neu: soft extrusion from the page surface.
|
|
45
|
+
@mixin chip {
|
|
46
|
+
box-shadow:
|
|
47
|
+
2px 2px 5px color-mix(in srgb, var(--surface) 55%, #000),
|
|
48
|
+
-2px -2px 5px color-mix(in srgb, var(--surface) 88%, #fff);
|
|
49
|
+
}
|