@alacris/ui 0.0.0 → 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 +148 -1
- package/package.json +75 -3
- package/src/components/base.js +46 -0
- package/src/components/ui-accordion-item.js +157 -0
- package/src/components/ui-accordion.js +58 -0
- package/src/components/ui-alert.js +168 -0
- package/src/components/ui-app-bar.js +119 -0
- package/src/components/ui-autocomplete.js +371 -0
- package/src/components/ui-avatar.js +104 -0
- package/src/components/ui-backdrop.js +66 -0
- package/src/components/ui-badge.js +95 -0
- package/src/components/ui-bottom-app-bar.js +78 -0
- package/src/components/ui-bottom-nav.js +83 -0
- package/src/components/ui-breadcrumbs.js +100 -0
- package/src/components/ui-button-group.js +49 -0
- package/src/components/ui-button.js +143 -0
- package/src/components/ui-card.js +86 -0
- package/src/components/ui-carousel-item.js +53 -0
- package/src/components/ui-carousel.js +301 -0
- package/src/components/ui-checkbox.js +165 -0
- package/src/components/ui-chip-set.js +90 -0
- package/src/components/ui-chip.js +226 -0
- package/src/components/ui-container.js +59 -0
- package/src/components/ui-date-picker.js +774 -0
- package/src/components/ui-dialog.js +179 -0
- package/src/components/ui-divider.js +58 -0
- package/src/components/ui-drawer.js +169 -0
- package/src/components/ui-fab-menu.js +122 -0
- package/src/components/ui-fab.js +117 -0
- package/src/components/ui-icon-button.js +112 -0
- package/src/components/ui-icon.js +64 -0
- package/src/components/ui-list-item.js +166 -0
- package/src/components/ui-list.js +38 -0
- package/src/components/ui-loading-indicator.js +74 -0
- package/src/components/ui-menu-item.js +117 -0
- package/src/components/ui-menu.js +192 -0
- package/src/components/ui-nav-item.js +138 -0
- package/src/components/ui-nav-rail.js +111 -0
- package/src/components/ui-option.js +85 -0
- package/src/components/ui-pagination.js +173 -0
- package/src/components/ui-progress.js +100 -0
- package/src/components/ui-radio-group.js +104 -0
- package/src/components/ui-radio.js +143 -0
- package/src/components/ui-rating.js +126 -0
- package/src/components/ui-search.js +316 -0
- package/src/components/ui-select.js +418 -0
- package/src/components/ui-sheet.js +205 -0
- package/src/components/ui-side-sheet.js +221 -0
- package/src/components/ui-skeleton.js +89 -0
- package/src/components/ui-slider.js +266 -0
- package/src/components/ui-snackbar.js +233 -0
- package/src/components/ui-spinner.js +101 -0
- package/src/components/ui-split-button.js +158 -0
- package/src/components/ui-stack.js +37 -0
- package/src/components/ui-step.js +91 -0
- package/src/components/ui-stepper.js +86 -0
- package/src/components/ui-surface.js +51 -0
- package/src/components/ui-switch.js +153 -0
- package/src/components/ui-tab-panel.js +54 -0
- package/src/components/ui-tab.js +116 -0
- package/src/components/ui-table-footer.js +141 -0
- package/src/components/ui-table-toolbar.js +219 -0
- package/src/components/ui-table.js +735 -0
- package/src/components/ui-tabs.js +178 -0
- package/src/components/ui-text-field.js +319 -0
- package/src/components/ui-text.js +36 -0
- package/src/components/ui-time-picker.js +753 -0
- package/src/components/ui-toggle-button.js +170 -0
- package/src/components/ui-toggle-group.js +111 -0
- package/src/components/ui-toolbar.js +64 -0
- package/src/components/ui-tooltip.js +148 -0
- package/src/index.js +121 -0
- package/src/motion/animate.js +145 -0
- package/src/motion/flip.js +46 -0
- package/src/motion/index.js +4 -0
- package/src/motion/presence.js +104 -0
- package/src/motion/ripple.js +108 -0
- package/src/theme/apply-theme.js +142 -0
- package/src/theme/create-theme.js +70 -0
- package/src/theme/index.js +5 -0
- package/src/tokens/color.js +237 -0
- package/src/tokens/index.js +14 -0
- package/src/tokens/sys.js +76 -0
- package/src/tokens/system.js +114 -0
- package/src/tokens/typography.js +189 -0
- package/src/util/focus.js +89 -0
- package/src/util/form.js +69 -0
- package/src/util/icons.js +161 -0
- package/src/util/keys.js +110 -0
- package/src/util/position.js +102 -0
- package/src/util/table.js +230 -0
- package/types/elements.d.ts +78 -0
- package/types/index.d.ts +168 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// <ui-dialog> — a modal dialog.
|
|
2
|
+
//
|
|
3
|
+
// <ui-dialog open=${open} @close=${() => open(false)}>
|
|
4
|
+
// <span slot="headline">Discard draft?</span>
|
|
5
|
+
// Your changes will be lost.
|
|
6
|
+
// <ui-button slot="actions" variant="text">Cancel</ui-button>
|
|
7
|
+
// <ui-button slot="actions">Discard</ui-button>
|
|
8
|
+
// </ui-dialog>
|
|
9
|
+
//
|
|
10
|
+
// Opening: set the `open` prop (pass a signal from the parent to stay live).
|
|
11
|
+
// The dialog animates in, traps focus, and locks page scroll. It requests
|
|
12
|
+
// closing by emitting `close` with a reason — the PARENT owns the state and
|
|
13
|
+
// flips the signal; Escape and scrim clicks emit `close` too (unless
|
|
14
|
+
// `persistent`).
|
|
15
|
+
//
|
|
16
|
+
// @prop {boolean} open=false
|
|
17
|
+
// @prop {boolean} persistent=false — Escape/scrim do not request closing
|
|
18
|
+
// @prop {string} label='' — accessible name if no headline slot
|
|
19
|
+
// @event close — detail: { reason: 'esc' | 'scrim' | 'method' }
|
|
20
|
+
// @event opened — enter animation finished
|
|
21
|
+
// @event closed — exit animation finished, DOM removed
|
|
22
|
+
// @slot (default) — body content
|
|
23
|
+
// @slot headline
|
|
24
|
+
// @slot actions — right-aligned buttons
|
|
25
|
+
// @part scrim, surface, headline, body, actions
|
|
26
|
+
// @vars see `t` below
|
|
27
|
+
|
|
28
|
+
import { define, html, css, vars, effect, onCleanup, signal } from '@alacris/core';
|
|
29
|
+
import { sys } from '../tokens/sys.js';
|
|
30
|
+
import { base } from './base.js';
|
|
31
|
+
import { presence } from '../motion/presence.js';
|
|
32
|
+
import { animate, fx } from '../motion/animate.js';
|
|
33
|
+
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
34
|
+
|
|
35
|
+
const t = vars('ui-dialog', {
|
|
36
|
+
bg: sys.color.surfaceContainerHigh,
|
|
37
|
+
fg: sys.color.onSurface,
|
|
38
|
+
headlineFg: sys.color.onSurface,
|
|
39
|
+
radius: sys.radius.xl,
|
|
40
|
+
width: 'min(560px, calc(100vw - 48px))',
|
|
41
|
+
scrim: `color-mix(in srgb, ${sys.color.scrim} 32%, transparent)`,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const styles = css`
|
|
45
|
+
:host { display: contents; }
|
|
46
|
+
.overlay {
|
|
47
|
+
position: fixed;
|
|
48
|
+
inset: 0;
|
|
49
|
+
z-index: ${sys.z.modal};
|
|
50
|
+
display: grid;
|
|
51
|
+
place-items: center;
|
|
52
|
+
}
|
|
53
|
+
.scrim { position: absolute; inset: 0; background: ${t.scrim}; }
|
|
54
|
+
@keyframes ui-dialog-in {
|
|
55
|
+
from { transform: scale(0.8); }
|
|
56
|
+
to { transform: none; }
|
|
57
|
+
}
|
|
58
|
+
.surface {
|
|
59
|
+
animation: ui-dialog-in ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
|
|
60
|
+
position: relative;
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
inline-size: ${t.width};
|
|
64
|
+
max-block-size: calc(100vh - 48px);
|
|
65
|
+
background: ${t.bg};
|
|
66
|
+
color: ${t.fg};
|
|
67
|
+
border-radius: ${t.radius};
|
|
68
|
+
box-shadow: ${sys.elevation[3]};
|
|
69
|
+
padding: ${sys.space(6)};
|
|
70
|
+
gap: ${sys.space(4)};
|
|
71
|
+
}
|
|
72
|
+
.headline {
|
|
73
|
+
font: ${sys.type.headlineSm};
|
|
74
|
+
letter-spacing: ${sys.tracking.headlineSm};
|
|
75
|
+
color: ${t.headlineFg};
|
|
76
|
+
}
|
|
77
|
+
.headline:not(.has) { display: none; }
|
|
78
|
+
.body {
|
|
79
|
+
overflow: auto;
|
|
80
|
+
font: ${sys.type.bodyMd};
|
|
81
|
+
letter-spacing: ${sys.tracking.bodyMd};
|
|
82
|
+
color: ${sys.color.onSurfaceVariant};
|
|
83
|
+
}
|
|
84
|
+
.actions {
|
|
85
|
+
display: flex;
|
|
86
|
+
justify-content: flex-end;
|
|
87
|
+
gap: ${sys.space(2)};
|
|
88
|
+
}
|
|
89
|
+
.actions:not(.has) { display: none; }
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
define('ui-dialog', {
|
|
93
|
+
props: { open: false, persistent: false, label: '' },
|
|
94
|
+
styles: [base, styles],
|
|
95
|
+
setup({ open, persistent, label }, host) {
|
|
96
|
+
let releaseTrap = null;
|
|
97
|
+
let unlock = null;
|
|
98
|
+
let surfaceEl = null;
|
|
99
|
+
const hasHeadline = signal(false);
|
|
100
|
+
|
|
101
|
+
const requestClose = (reason) => {
|
|
102
|
+
if (reason !== 'method' && persistent()) return;
|
|
103
|
+
host.emit('close', { reason });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
// Escape must work wherever focus is, so listen at the document while
|
|
108
|
+
// open (capture beats stopPropagation games in page code).
|
|
109
|
+
const onDocKeydown = (e) => {
|
|
110
|
+
if (e.key === 'Escape') requestClose('esc');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Trap focus + lock scroll exactly while open; presence handles the DOM.
|
|
114
|
+
effect(() => {
|
|
115
|
+
if (open()) {
|
|
116
|
+
document.addEventListener('keydown', onDocKeydown, true);
|
|
117
|
+
unlock = scrollLock();
|
|
118
|
+
// The surface renders synchronously with the signal write, but wait a
|
|
119
|
+
// microtask so slotted content is distributed before we look for it.
|
|
120
|
+
queueMicrotask(() => {
|
|
121
|
+
if (open() && !releaseTrap) releaseTrap = focusTrap(host);
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
125
|
+
releaseTrap?.();
|
|
126
|
+
releaseTrap = null;
|
|
127
|
+
unlock?.();
|
|
128
|
+
unlock = null;
|
|
129
|
+
if (surfaceEl?.isConnected) {
|
|
130
|
+
animate(surfaceEl, fx.scaleOut, { duration: 'short4', easing: 'emphasizedAccelerate' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
onCleanup(() => {
|
|
135
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
136
|
+
releaseTrap?.();
|
|
137
|
+
unlock?.();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const hasSlot = (el, set) => {
|
|
141
|
+
const sync = () => set(el.assignedElements().length > 0);
|
|
142
|
+
el.addEventListener('slotchange', sync);
|
|
143
|
+
sync();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const view = () => html`
|
|
147
|
+
<div class="overlay">
|
|
148
|
+
<div class="scrim" part="scrim" aria-hidden="true" @click=${() => requestClose('scrim')}></div>
|
|
149
|
+
<div class="surface" part="surface" role="dialog" aria-modal="true"
|
|
150
|
+
aria-labelledby=${() => (hasHeadline() ? 'headline' : null)}
|
|
151
|
+
aria-label=${() => (hasHeadline() ? null : (label() || 'Dialog'))}
|
|
152
|
+
tabindex="-1" ref=${(el) => (surfaceEl = el)}>
|
|
153
|
+
<div class="headline" part="headline" id="headline"
|
|
154
|
+
ref=${(el) => hasSlot(el.querySelector('slot'), (has) => {
|
|
155
|
+
hasHeadline.set(has);
|
|
156
|
+
el.classList.toggle('has', has);
|
|
157
|
+
})}>
|
|
158
|
+
<slot name="headline"></slot>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="body" part="body"><slot></slot></div>
|
|
161
|
+
<div class="actions" part="actions"
|
|
162
|
+
ref=${(el) => hasSlot(el.querySelector('slot'), (has) => el.classList.toggle('has', has))}>
|
|
163
|
+
<slot name="actions"></slot>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>`;
|
|
167
|
+
|
|
168
|
+
return html`${presence(open, view, {
|
|
169
|
+
enter: fx.fadeIn,
|
|
170
|
+
exit: fx.fadeOut,
|
|
171
|
+
exitDuration: 'short4',
|
|
172
|
+
onEntered: () => host.emit('opened'),
|
|
173
|
+
onExited: () => host.emit('closed'),
|
|
174
|
+
})}`;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export const tag = 'ui-dialog';
|
|
179
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// <ui-divider> — a 1px rule separating content, horizontal or vertical.
|
|
2
|
+
//
|
|
3
|
+
// Props reflect to host attributes so the styling is pure CSS on :host.
|
|
4
|
+
//
|
|
5
|
+
// @prop {string} orientation='horizontal' — horizontal | vertical
|
|
6
|
+
// @prop {boolean} inset=false — indented from the start edge (16px)
|
|
7
|
+
// @prop {boolean} middle=false — indented from both edges
|
|
8
|
+
// @vars --ui-divider-color, --ui-divider-thickness
|
|
9
|
+
//
|
|
10
|
+
// role="separator" with aria-orientation.
|
|
11
|
+
|
|
12
|
+
import { define, html, css, vars, effect } from '@alacris/core';
|
|
13
|
+
import { sys } from '../tokens/sys.js';
|
|
14
|
+
import { base } from './base.js';
|
|
15
|
+
|
|
16
|
+
const t = vars('ui-divider', {
|
|
17
|
+
color: sys.color.outlineVariant,
|
|
18
|
+
thickness: '1px',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const styles = css`
|
|
22
|
+
:host {
|
|
23
|
+
display: block;
|
|
24
|
+
flex: none;
|
|
25
|
+
align-self: stretch;
|
|
26
|
+
background: ${t.color};
|
|
27
|
+
block-size: ${t.thickness};
|
|
28
|
+
margin: 0;
|
|
29
|
+
}
|
|
30
|
+
:host([inset]) { margin-inline-start: ${sys.space(4)}; }
|
|
31
|
+
:host([middle]) { margin-inline: ${sys.space(4)}; }
|
|
32
|
+
:host([orientation="vertical"]) {
|
|
33
|
+
inline-size: ${t.thickness};
|
|
34
|
+
block-size: auto;
|
|
35
|
+
min-block-size: 1em;
|
|
36
|
+
margin-inline: 0;
|
|
37
|
+
}
|
|
38
|
+
:host([orientation="vertical"][inset]) { margin-inline: 0; margin-block-start: ${sys.space(4)}; }
|
|
39
|
+
:host([orientation="vertical"][middle]) { margin-inline: 0; margin-block: ${sys.space(4)}; }
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
define('ui-divider', {
|
|
43
|
+
props: { orientation: 'horizontal', inset: false, middle: false },
|
|
44
|
+
styles: [base, styles],
|
|
45
|
+
setup({ orientation, inset, middle }, host) {
|
|
46
|
+
host.setAttribute('role', 'separator');
|
|
47
|
+
effect(() => {
|
|
48
|
+
host.setAttribute('orientation', orientation());
|
|
49
|
+
host.setAttribute('aria-orientation', orientation());
|
|
50
|
+
});
|
|
51
|
+
effect(() => host.toggleAttribute('inset', inset()));
|
|
52
|
+
effect(() => host.toggleAttribute('middle', middle()));
|
|
53
|
+
return html``;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const tag = 'ui-divider';
|
|
58
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// <ui-drawer> — a navigation drawer, modal or standard.
|
|
2
|
+
//
|
|
3
|
+
// <ui-drawer open=${open} @close=${() => open(false)}>…nav content…</ui-drawer>
|
|
4
|
+
// <ui-drawer variant="standard" anchor="start" open=${open}>…</ui-drawer>
|
|
5
|
+
//
|
|
6
|
+
// Modal: a fixed overlay — scrim plus a full-height panel that slides in from
|
|
7
|
+
// the anchor side. Focus is trapped and page scroll locked while open. The
|
|
8
|
+
// PARENT owns `open`: Escape and scrim clicks emit `close` with a reason and
|
|
9
|
+
// the parent flips the signal. `opened`/`closed` fire after the enter/exit
|
|
10
|
+
// animations settle.
|
|
11
|
+
// Standard: an in-flow panel; the host animates its inline size open/closed —
|
|
12
|
+
// no scrim, no trap.
|
|
13
|
+
//
|
|
14
|
+
// @prop {boolean} open=false
|
|
15
|
+
// @prop {string} variant='modal' — modal | standard
|
|
16
|
+
// @prop {string} anchor='start' — start | end (which edge it slides from)
|
|
17
|
+
// @prop {string} label='' — accessible name; falls back to "Navigation"
|
|
18
|
+
// @event close — modal dismissed; detail: { reason: 'esc' | 'scrim' }
|
|
19
|
+
// @event opened — modal enter animation finished
|
|
20
|
+
// @event closed — modal exit animation finished, DOM removed
|
|
21
|
+
// @slot (default) — drawer content
|
|
22
|
+
// @part surface, scrim
|
|
23
|
+
// @vars see `t` below (`themeVars.names`)
|
|
24
|
+
|
|
25
|
+
import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
|
|
26
|
+
import { sys } from '../tokens/sys.js';
|
|
27
|
+
import { base } from './base.js';
|
|
28
|
+
import { presence } from '../motion/presence.js';
|
|
29
|
+
import { animate, fx } from '../motion/animate.js';
|
|
30
|
+
import { focusTrap, scrollLock } from '../util/focus.js';
|
|
31
|
+
|
|
32
|
+
const t = vars('ui-drawer', {
|
|
33
|
+
bg: sys.color.surfaceContainerLow,
|
|
34
|
+
stdBg: sys.color.surface,
|
|
35
|
+
fg: sys.color.onSurface,
|
|
36
|
+
width: 'min(360px, 80vw)',
|
|
37
|
+
radius: sys.radius.lg,
|
|
38
|
+
pad: sys.space(3),
|
|
39
|
+
scrim: `color-mix(in srgb, ${sys.color.scrim} 32%, transparent)`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const styles = css`
|
|
43
|
+
:host { display: block; inline-size: fit-content; }
|
|
44
|
+
.overlay {
|
|
45
|
+
position: fixed;
|
|
46
|
+
inset: 0;
|
|
47
|
+
z-index: ${sys.z.drawer};
|
|
48
|
+
}
|
|
49
|
+
.scrim { position: absolute; inset: 0; background: ${t.scrim}; }
|
|
50
|
+
.surface {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset-block: 0;
|
|
53
|
+
inline-size: ${t.width};
|
|
54
|
+
background: ${t.bg};
|
|
55
|
+
color: ${t.fg};
|
|
56
|
+
padding: ${t.pad};
|
|
57
|
+
overflow-y: auto;
|
|
58
|
+
box-shadow: ${sys.elevation[1]};
|
|
59
|
+
}
|
|
60
|
+
.surface.start {
|
|
61
|
+
inset-inline-start: 0;
|
|
62
|
+
border-start-end-radius: ${t.radius};
|
|
63
|
+
border-end-end-radius: ${t.radius};
|
|
64
|
+
}
|
|
65
|
+
.surface.end {
|
|
66
|
+
inset-inline-end: 0;
|
|
67
|
+
border-start-start-radius: ${t.radius};
|
|
68
|
+
border-end-start-radius: ${t.radius};
|
|
69
|
+
}
|
|
70
|
+
.std {
|
|
71
|
+
inline-size: 0;
|
|
72
|
+
block-size: 100%;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
background: ${t.stdBg};
|
|
75
|
+
color: ${t.fg};
|
|
76
|
+
transition: inline-size ${sys.duration.medium2} ${sys.easing.emphasized};
|
|
77
|
+
}
|
|
78
|
+
.std.open { inline-size: ${t.width}; }
|
|
79
|
+
.std.start {
|
|
80
|
+
border-start-end-radius: ${t.radius};
|
|
81
|
+
border-end-end-radius: ${t.radius};
|
|
82
|
+
}
|
|
83
|
+
.std.end {
|
|
84
|
+
border-start-start-radius: ${t.radius};
|
|
85
|
+
border-end-start-radius: ${t.radius};
|
|
86
|
+
}
|
|
87
|
+
.std .inner { padding: ${t.pad}; inline-size: ${t.width}; }
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
define('ui-drawer', {
|
|
91
|
+
props: { open: false, variant: 'modal', anchor: 'start', label: '' },
|
|
92
|
+
styles: [base, styles],
|
|
93
|
+
setup({ open, variant, anchor, label }, host) {
|
|
94
|
+
let releaseTrap = null;
|
|
95
|
+
let unlock = null;
|
|
96
|
+
let surfaceEl = null;
|
|
97
|
+
|
|
98
|
+
const slideIn = () => (anchor.peek() === 'start' ? fx.slideInLeft : fx.slideInRight);
|
|
99
|
+
const slideOut = () => (anchor.peek() === 'start' ? fx.slideOutLeft : fx.slideOutRight);
|
|
100
|
+
|
|
101
|
+
// Escape must work wherever focus is, so listen at the document while
|
|
102
|
+
// the modal drawer is open.
|
|
103
|
+
const onDocKeydown = (e) => {
|
|
104
|
+
if (e.key === 'Escape') host.emit('close', { reason: 'esc' });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Trap focus + lock scroll exactly while the modal drawer is open.
|
|
108
|
+
effect(() => {
|
|
109
|
+
if (open() && variant() === 'modal') {
|
|
110
|
+
document.addEventListener('keydown', onDocKeydown, true);
|
|
111
|
+
if (!unlock) unlock = scrollLock();
|
|
112
|
+
queueMicrotask(() => {
|
|
113
|
+
if (open.peek() && !releaseTrap) releaseTrap = focusTrap(host);
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
117
|
+
releaseTrap?.();
|
|
118
|
+
releaseTrap = null;
|
|
119
|
+
unlock?.();
|
|
120
|
+
unlock = null;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
onCleanup(() => {
|
|
124
|
+
document.removeEventListener('keydown', onDocKeydown, true);
|
|
125
|
+
releaseTrap?.();
|
|
126
|
+
unlock?.();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// The panel slides out while the presence overlay (scrim included) fades.
|
|
130
|
+
effect(() => {
|
|
131
|
+
if (!open() && surfaceEl?.isConnected) {
|
|
132
|
+
animate(surfaceEl, slideOut(), { duration: 'short4', easing: 'emphasizedAccelerate' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const surfaceRef = (el) => {
|
|
137
|
+
surfaceEl = el;
|
|
138
|
+
animate(el, slideIn(), { duration: 'medium2', easing: 'emphasizedDecelerate' });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const overlay = () => html`
|
|
142
|
+
<div class="overlay">
|
|
143
|
+
<div class="scrim" part="scrim" aria-hidden="true" @click=${() => host.emit('close', { reason: 'scrim' })}></div>
|
|
144
|
+
<aside class=${() => `surface ${anchor()}`} part="surface" role="dialog" aria-modal="true"
|
|
145
|
+
aria-label=${() => label() || 'Navigation'} tabindex="-1" ref=${surfaceRef}>
|
|
146
|
+
<slot></slot>
|
|
147
|
+
</aside>
|
|
148
|
+
</div>`;
|
|
149
|
+
|
|
150
|
+
return html`
|
|
151
|
+
${() =>
|
|
152
|
+
variant() === 'standard'
|
|
153
|
+
? html`<aside class=${() => `std ${anchor()}${open() ? ' open' : ''}`} part="surface"
|
|
154
|
+
aria-label=${() => label() || 'Navigation'}>
|
|
155
|
+
<div class="inner"><slot></slot></div>
|
|
156
|
+
</aside>`
|
|
157
|
+
: null}
|
|
158
|
+
${presence(() => open() && variant() === 'modal', overlay, {
|
|
159
|
+
enter: fx.fadeIn,
|
|
160
|
+
exit: fx.fadeOut,
|
|
161
|
+
exitDuration: 'short4',
|
|
162
|
+
onEntered: () => host.emit('opened'),
|
|
163
|
+
onExited: () => host.emit('closed'),
|
|
164
|
+
})}`;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export const tag = 'ui-drawer';
|
|
169
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// <ui-fab-menu> — a Material FAB menu: a trigger FAB that expands related
|
|
2
|
+
// actions stacked above it.
|
|
3
|
+
//
|
|
4
|
+
// <ui-fab-menu>
|
|
5
|
+
// <ui-fab slot="trigger" icon="add"></ui-fab>
|
|
6
|
+
// <ui-fab icon="edit" label="Edit" size="sm"></ui-fab>
|
|
7
|
+
// <ui-fab icon="send" label="Send" size="sm"></ui-fab>
|
|
8
|
+
// </ui-fab-menu>
|
|
9
|
+
//
|
|
10
|
+
// The PARENT may pass `open`; clicking the trigger toggles it and emits
|
|
11
|
+
// `open`/`close`. Related actions are a disclosure of buttons (not a menu
|
|
12
|
+
// widget) so slotted `<ui-fab>`s keep their native button semantics.
|
|
13
|
+
// Escape closes and returns focus to the trigger.
|
|
14
|
+
//
|
|
15
|
+
// @prop {boolean} open=false
|
|
16
|
+
// @prop {string} label='' — accessible name for the action list
|
|
17
|
+
// @event open — menu visible (after the enter animation)
|
|
18
|
+
// @event close — menu removed (after the exit animation)
|
|
19
|
+
// @slot trigger — the <ui-fab> that toggles the menu
|
|
20
|
+
// @slot (default) — related <ui-fab> actions
|
|
21
|
+
// @part actions, trigger
|
|
22
|
+
// @vars see `t` below (`themeVars.names`)
|
|
23
|
+
|
|
24
|
+
import { define, html, css, vars, effect, onCleanup } from '@alacris/core';
|
|
25
|
+
import { sys } from '../tokens/sys.js';
|
|
26
|
+
import { base } from './base.js';
|
|
27
|
+
import { presence } from '../motion/presence.js';
|
|
28
|
+
import { fx } from '../motion/animate.js';
|
|
29
|
+
import './ui-fab.js';
|
|
30
|
+
|
|
31
|
+
const t = vars('ui-fab-menu', {
|
|
32
|
+
gap: sys.space(4),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const styles = css`
|
|
36
|
+
:host {
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
flex-direction: column-reverse;
|
|
39
|
+
align-items: flex-end;
|
|
40
|
+
gap: ${t.gap};
|
|
41
|
+
}
|
|
42
|
+
.actions {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column-reverse;
|
|
45
|
+
align-items: flex-end;
|
|
46
|
+
gap: ${t.gap};
|
|
47
|
+
}
|
|
48
|
+
.trigger { display: inline-flex; }
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
define('ui-fab-menu', {
|
|
52
|
+
props: { open: false, label: '' },
|
|
53
|
+
styles: [base, styles],
|
|
54
|
+
setup({ open, label }, host) {
|
|
55
|
+
const triggerEl = () => host.querySelector('[slot="trigger"]');
|
|
56
|
+
const triggerControl = () => {
|
|
57
|
+
const el = triggerEl();
|
|
58
|
+
return el?.shadowRoot?.querySelector('button, a[href]') || el;
|
|
59
|
+
};
|
|
60
|
+
const syncTrigger = () => {
|
|
61
|
+
const control = triggerControl();
|
|
62
|
+
if (!control) return;
|
|
63
|
+
control.setAttribute('aria-haspopup', 'true');
|
|
64
|
+
control.setAttribute('aria-expanded', open.peek() ? 'true' : 'false');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
host.addEventListener('click', (e) => {
|
|
68
|
+
const trigger = triggerEl();
|
|
69
|
+
if (trigger && e.composedPath().includes(trigger)) {
|
|
70
|
+
open.set(!open.peek());
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (open.peek() && host.contains(e.target) && e.target !== host) {
|
|
74
|
+
open.set(false);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
effect(() => {
|
|
79
|
+
open();
|
|
80
|
+
queueMicrotask(syncTrigger);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
effect(() => {
|
|
84
|
+
if (!open()) return;
|
|
85
|
+
const onKey = (e) => {
|
|
86
|
+
if (e.key !== 'Escape') return;
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
open.set(false);
|
|
89
|
+
triggerControl()?.focus?.();
|
|
90
|
+
};
|
|
91
|
+
document.addEventListener('keydown', onKey, true);
|
|
92
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
93
|
+
});
|
|
94
|
+
onCleanup(() => {
|
|
95
|
+
const control = triggerControl();
|
|
96
|
+
control?.removeAttribute('aria-haspopup');
|
|
97
|
+
control?.removeAttribute('aria-expanded');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const actionsView = () => html`
|
|
101
|
+
<div class="actions" part="actions" role="group"
|
|
102
|
+
aria-label=${() => label() || 'Actions'}>
|
|
103
|
+
<slot></slot>
|
|
104
|
+
</div>`;
|
|
105
|
+
|
|
106
|
+
return html`
|
|
107
|
+
<div class="trigger" part="trigger">
|
|
108
|
+
<slot name="trigger"></slot>
|
|
109
|
+
</div>
|
|
110
|
+
${presence(open, actionsView, {
|
|
111
|
+
enter: fx.scaleIn,
|
|
112
|
+
exit: fx.scaleOut,
|
|
113
|
+
enterDuration: 'short4',
|
|
114
|
+
exitDuration: 'short2',
|
|
115
|
+
onEntered: () => host.emit('open'),
|
|
116
|
+
onExited: () => host.emit('close'),
|
|
117
|
+
})}`;
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export const tag = 'ui-fab-menu';
|
|
122
|
+
export const themeVars = t;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// <ui-fab> — the Material floating action button, regular and extended.
|
|
2
|
+
//
|
|
3
|
+
// A non-empty `label` renders the extended FAB (icon + text) and is always the
|
|
4
|
+
// accessible name, extended or not.
|
|
5
|
+
//
|
|
6
|
+
// @prop {string} icon='' — registry icon name (or slot custom content)
|
|
7
|
+
// @prop {string} label='' — extended-FAB text; always used as aria-label
|
|
8
|
+
// (falls back to the icon name when empty)
|
|
9
|
+
// @prop {string} variant='primary' — primary | secondary | tertiary | surface
|
|
10
|
+
// @prop {string} size='md' — sm (40px) | md (56px) | lg (96px)
|
|
11
|
+
// @prop {boolean} disabled=false
|
|
12
|
+
// @event (native click bubbles; no custom event)
|
|
13
|
+
// @slot (default) — custom icon content when `icon` is empty
|
|
14
|
+
// @part control — the <button>
|
|
15
|
+
// @vars see `t` below (`themeVars.names`)
|
|
16
|
+
|
|
17
|
+
import { define, html, css, vars, computed } from '@alacris/core';
|
|
18
|
+
import { sys } from '../tokens/sys.js';
|
|
19
|
+
import { base, focusRingOn } from './base.js';
|
|
20
|
+
import { ripple } from '../motion/ripple.js';
|
|
21
|
+
import './ui-icon.js';
|
|
22
|
+
|
|
23
|
+
const t = vars('ui-fab', {
|
|
24
|
+
sizeSm: '40px',
|
|
25
|
+
sizeMd: '56px',
|
|
26
|
+
sizeLg: '96px',
|
|
27
|
+
radiusSm: sys.radius.md,
|
|
28
|
+
radiusMd: sys.radius.lg,
|
|
29
|
+
radiusLg: sys.radius.xl,
|
|
30
|
+
font: sys.type.labelLg,
|
|
31
|
+
tracking: sys.tracking.labelLg,
|
|
32
|
+
primaryBg: sys.color.primaryContainer,
|
|
33
|
+
primaryFg: sys.color.onPrimaryContainer,
|
|
34
|
+
secondaryBg: sys.color.secondaryContainer,
|
|
35
|
+
secondaryFg: sys.color.onSecondaryContainer,
|
|
36
|
+
tertiaryBg: sys.color.tertiaryContainer,
|
|
37
|
+
tertiaryFg: sys.color.onTertiaryContainer,
|
|
38
|
+
surfaceBg: sys.color.surfaceContainerHigh,
|
|
39
|
+
surfaceFg: sys.color.primary,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const styles = css`
|
|
43
|
+
:host { display: inline-flex; vertical-align: middle; }
|
|
44
|
+
.control {
|
|
45
|
+
position: relative;
|
|
46
|
+
isolation: isolate;
|
|
47
|
+
display: inline-flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
gap: ${sys.space(3)};
|
|
51
|
+
block-size: calc(var(--_size) + var(--ui-density, 0) * 4px);
|
|
52
|
+
min-inline-size: calc(var(--_size) + var(--ui-density, 0) * 4px);
|
|
53
|
+
padding: 0;
|
|
54
|
+
border: none;
|
|
55
|
+
border-radius: var(--_radius);
|
|
56
|
+
font: ${t.font};
|
|
57
|
+
letter-spacing: ${t.tracking};
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
user-select: none;
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
box-shadow: ${sys.elevation[3]};
|
|
62
|
+
transition: box-shadow ${sys.duration.short4} ${sys.easing.standard};
|
|
63
|
+
--ui-icon-size: var(--_icon);
|
|
64
|
+
}
|
|
65
|
+
.control:hover { box-shadow: ${sys.elevation[4]}; }
|
|
66
|
+
.control:active { box-shadow: ${sys.elevation[3]}; }
|
|
67
|
+
${focusRingOn('.control')}
|
|
68
|
+
.layer {
|
|
69
|
+
position: absolute; inset: 0; z-index: -1;
|
|
70
|
+
border-radius: inherit;
|
|
71
|
+
background: currentColor;
|
|
72
|
+
opacity: 0;
|
|
73
|
+
transition: opacity ${sys.duration.short2} ${sys.easing.standard};
|
|
74
|
+
}
|
|
75
|
+
.control:hover .layer { opacity: ${sys.state.hover}; }
|
|
76
|
+
.control:focus-visible .layer { opacity: ${sys.state.focus}; }
|
|
77
|
+
.control:active .layer { opacity: ${sys.state.pressed}; }
|
|
78
|
+
|
|
79
|
+
.sm { --_size: ${t.sizeSm}; --_radius: ${t.radiusSm}; --_icon: 1.5rem; }
|
|
80
|
+
.md { --_size: ${t.sizeMd}; --_radius: ${t.radiusMd}; --_icon: 1.5rem; }
|
|
81
|
+
.lg { --_size: ${t.sizeLg}; --_radius: ${t.radiusLg}; --_icon: 2.25rem; }
|
|
82
|
+
.extended { padding-inline: ${sys.space(4)} 20px; --_icon: 1.5rem; }
|
|
83
|
+
|
|
84
|
+
.primary { background: ${t.primaryBg}; color: ${t.primaryFg}; }
|
|
85
|
+
.secondary { background: ${t.secondaryBg}; color: ${t.secondaryFg}; }
|
|
86
|
+
.tertiary { background: ${t.tertiaryBg}; color: ${t.tertiaryFg}; }
|
|
87
|
+
.surface { background: ${t.surfaceBg}; color: ${t.surfaceFg}; }
|
|
88
|
+
|
|
89
|
+
.control:disabled {
|
|
90
|
+
cursor: default;
|
|
91
|
+
pointer-events: none;
|
|
92
|
+
box-shadow: none;
|
|
93
|
+
color: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContent} * 100%), transparent);
|
|
94
|
+
background: color-mix(in srgb, ${sys.color.onSurface} calc(${sys.state.disabledContainer} * 100%), transparent);
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
define('ui-fab', {
|
|
99
|
+
props: { icon: '', label: '', variant: 'primary', size: 'md', disabled: false },
|
|
100
|
+
styles: [base, styles],
|
|
101
|
+
setup({ icon, label, variant, size, disabled }, host) {
|
|
102
|
+
const cls = computed(() =>
|
|
103
|
+
`control ${variant()} ${size()}${label() ? ' extended' : ''}`);
|
|
104
|
+
|
|
105
|
+
return html`
|
|
106
|
+
<button part="control" class=${cls} type="button" ?disabled=${disabled}
|
|
107
|
+
aria-label=${() => label() || icon() || null}
|
|
108
|
+
ref=${(el) => ripple(el, { disabled })}>
|
|
109
|
+
<span class="layer" aria-hidden="true"></span>
|
|
110
|
+
${() => (icon() ? html`<ui-icon name=${icon}></ui-icon>` : html`<slot></slot>`)}
|
|
111
|
+
${() => (label() ? html`<span class="text">${label}</span>` : null)}
|
|
112
|
+
</button>`;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const tag = 'ui-fab';
|
|
117
|
+
export const themeVars = t;
|