@apliteni/apliteni-ui 0.23.4 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.23.4",
3
+ "version": "0.24.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -76,6 +76,8 @@ function ddBody({ items, sections }, listbox) {
76
76
  * @param {string} [o.header] raw HTML pinned to the top of the panel
77
77
  * @param {string} [o.footer] raw HTML pinned to the bottom of the panel
78
78
  * @param {string} [o.align] 'start' (default) | 'end' — the edge the panel hugs
79
+ * @param {string} [o.direction] 'down' (default) | 'up' | 'auto' — the way it opens
80
+ * @param {boolean} [o.portal] mount the panel on <body>, for a clipping or sticky ancestor
79
81
  * @param {boolean|number} [o.scroll] true, or a maxHeight in px, to cap and scroll
80
82
  * @param {boolean} [o.open] render already-open (handy for screenshots)
81
83
  * @param {string} [o.ariaLabel] accessible name for the panel and trigger
@@ -84,7 +86,8 @@ function ddBody({ items, sections }, listbox) {
84
86
  export function dropdown({
85
87
  label, value, placeholder = 'Select…', variant, items, sections,
86
88
  header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
87
- align = 'start', scroll = false, open = false, ariaLabel, id, panelClass = '',
89
+ align = 'start', direction = 'down', portal = false,
90
+ scroll = false, open = false, ariaLabel, id, panelClass = '',
88
91
  } = {}) {
89
92
  const flat = sections ? sections.flatMap((s) => s.items || []) : (items || []);
90
93
  const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
@@ -104,15 +107,30 @@ export function dropdown({
104
107
  ariaLabel && triggerContent != null ? `aria-label="${esc(ariaLabel)}"` : '',
105
108
  ].filter(Boolean).join(' ');
106
109
 
110
+ // `is-open` beside `open` because the descendant selector the panel normally
111
+ // takes its open state from stops matching once wireDropdown() portals it.
107
112
  const panelAttrs = [
108
- `class="${cx('ui-dropdown__panel', align === 'end' && 'is-end', scroll && 'is-scroll', panelClass)}"`,
113
+ `class="${cx(
114
+ 'ui-dropdown__panel',
115
+ align === 'end' && 'is-end',
116
+ direction === 'up' && 'is-up',
117
+ scroll && 'is-scroll',
118
+ portal && 'ui-dropdown__panel--portal',
119
+ portal && open && 'is-open',
120
+ panelClass,
121
+ )}"`,
109
122
  'data-dropdown-panel',
110
123
  `role="${listRole}"`,
111
124
  ariaLabel ? `aria-label="${esc(ariaLabel)}"` : '',
112
125
  scroll && scroll !== true ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
113
126
  ].filter(Boolean).join(' ');
114
127
 
115
- return `<div class="${cx('ui-dropdown', open && 'open')}" data-dropdown${isSelect ? ' data-dropdown-select' : ''}${id ? ` id="${esc(id)}"` : ''}>` +
128
+ const ddAttrs = 'data-dropdown'
129
+ + (isSelect ? ' data-dropdown-select' : '')
130
+ + (direction === 'auto' ? ' data-dropdown-direction="auto"' : '')
131
+ + (portal ? ' data-dropdown-portal' : '');
132
+
133
+ return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
116
134
  `<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
117
135
  `<div ${panelAttrs}>${header}${ddBody({ items, sections }, isSelect)}${footer}</div>` +
118
136
  `</div>`;
@@ -129,16 +147,74 @@ export function dropdown({
129
147
  // document. Safe to call repeatedly (e.g. Storybook re-renders).
130
148
  let _ddGlobalWired = false;
131
149
 
150
+ // The trigger-to-panel offset is --ui-dropdown-gap in src/styles/dropdown.css.
151
+ // This is the fallback for a document that has not loaded the sheet;
152
+ // src/components/dropdown.test.js pins the two to each other.
153
+ const DD_GAP = 9;
154
+
155
+ // A portalled panel is no longer a descendant of its container, so everything
156
+ // below asks the container for its panel rather than querying inside it.
157
+ const ddPanelOf = (dd) => dd.__ddPanel || dd.querySelector('[data-dropdown-panel]');
158
+
159
+ function ddGap(panel) {
160
+ const declared = parseFloat(getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
161
+ return Number.isFinite(declared) ? declared : DD_GAP;
162
+ }
163
+
132
164
  function ddItemsOf(dd) {
133
- const panel = dd.querySelector('[data-dropdown-panel]');
165
+ const panel = ddPanelOf(dd);
134
166
  if (!panel) return [];
135
167
  return Array.from(panel.querySelectorAll('[data-dd-item]'))
136
168
  .filter((el) => el.getAttribute('aria-disabled') !== 'true');
137
169
  }
138
170
 
171
+ // `auto` is the only direction the wiring decides; `up` and the default are the
172
+ // panel's own class, set once at render. Flip only when below is too tight AND
173
+ // above is roomier, so a panel that fits nowhere still opens the way it says.
174
+ function ddResolveDirection(dd, panel) {
175
+ if (dd.getAttribute('data-dropdown-direction') !== 'auto') return;
176
+ const trigger = dd.querySelector('[data-dropdown-trigger]');
177
+ if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
178
+ const t = trigger.getBoundingClientRect();
179
+ const below = window.innerHeight - t.bottom;
180
+ panel.classList.toggle('is-up', below < panel.offsetHeight + ddGap(panel) && t.top > below);
181
+ }
182
+
183
+ // Nothing lays a portalled panel out any more, so these four inline values are
184
+ // its layout. Inline, so no rule in any sheet can pin the opposite edge.
185
+ function positionPortalPanel(dd, panel) {
186
+ const trigger = dd.querySelector('[data-dropdown-trigger]');
187
+ if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
188
+ const t = trigger.getBoundingClientRect();
189
+ const gap = ddGap(panel);
190
+ const s = panel.style;
191
+ if (panel.classList.contains('is-up')) {
192
+ s.top = 'auto';
193
+ s.bottom = `${window.innerHeight - t.top + gap}px`;
194
+ } else {
195
+ s.bottom = 'auto';
196
+ s.top = `${t.bottom + gap}px`;
197
+ }
198
+ if (panel.classList.contains('is-end')) {
199
+ s.left = 'auto';
200
+ s.right = `${window.innerWidth - t.right}px`;
201
+ } else {
202
+ s.right = 'auto';
203
+ s.left = `${t.left}px`;
204
+ }
205
+ }
206
+
207
+ // A panel left on <body> outlives the container that owned it — a re-render
208
+ // replaces the container and the old panel has nothing pointing at it.
209
+ function sweepOrphanPanels() {
210
+ document.querySelectorAll('body > [data-dropdown-panel][data-dropdown-portal]')
211
+ .forEach((p) => { if (!p.__ddOwner || !p.__ddOwner.isConnected) p.remove(); });
212
+ }
213
+
139
214
  function closeDropdown(dd) {
140
215
  if (!dd.classList.contains('open')) return;
141
216
  dd.classList.remove('open');
217
+ ddPanelOf(dd)?.classList.remove('is-open');
142
218
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
143
219
  }
144
220
 
@@ -148,6 +224,11 @@ function closeAllDropdowns(except) {
148
224
 
149
225
  function openDropdown(dd, focusIdx) {
150
226
  closeAllDropdowns(dd);
227
+ const panel = ddPanelOf(dd);
228
+ if (panel) {
229
+ ddResolveDirection(dd, panel);
230
+ if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
231
+ }
151
232
  dd.classList.add('open');
152
233
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
153
234
  if (focusIdx != null) {
@@ -161,7 +242,7 @@ function openDropdown(dd, focusIdx) {
161
242
  function selectOption(dd, item) {
162
243
  if (!dd.hasAttribute('data-dropdown-select')) return;
163
244
  ddItemsOf(dd).forEach((el) => el.setAttribute('aria-selected', el === item ? 'true' : 'false'));
164
- dd.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
245
+ ddPanelOf(dd)?.querySelectorAll('[data-dd-item].is-selected').forEach((el) => el.classList.remove('is-selected'));
165
246
  item.classList.add('is-selected');
166
247
  const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
167
248
  const label = item.querySelector('.ui-dropdown__label');
@@ -177,6 +258,23 @@ export function wireDropdown(root = document) {
177
258
  const panel = dd.querySelector('[data-dropdown-panel]');
178
259
  if (!trigger) return;
179
260
 
261
+ // Portal: lift the panel onto <body>. An ancestor whose overflow is not
262
+ // `visible` clips it on both axes, and one that is `position: sticky` opens
263
+ // a stacking context whatever z-index the panel carries — the app rail is
264
+ // both at once. why: docs/specification.md#the-dropdown-panel
265
+ if (panel && dd.hasAttribute('data-dropdown-portal')) {
266
+ sweepOrphanPanels();
267
+ panel.setAttribute('data-dropdown-portal', '');
268
+ panel.__ddOwner = dd;
269
+ dd.__ddPanel = panel;
270
+ document.body.appendChild(panel);
271
+ if (dd.classList.contains('open')) {
272
+ ddResolveDirection(dd, panel);
273
+ positionPortalPanel(dd, panel);
274
+ panel.classList.add('is-open');
275
+ }
276
+ }
277
+
180
278
  trigger.addEventListener('click', (e) => {
181
279
  e.stopPropagation();
182
280
  if (dd.classList.contains('open')) closeDropdown(dd);
@@ -196,7 +294,7 @@ export function wireDropdown(root = document) {
196
294
  });
197
295
  }
198
296
 
199
- dd.addEventListener('keydown', (e) => {
297
+ const onKeydown = (e) => {
200
298
  const open = dd.classList.contains('open');
201
299
  const onTrigger = e.target === trigger;
202
300
  if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && (onTrigger || open)) {
@@ -217,7 +315,22 @@ export function wireDropdown(root = document) {
217
315
  } else if (e.key === 'Tab' && open) {
218
316
  closeDropdown(dd);
219
317
  }
220
- });
318
+ };
319
+
320
+ dd.addEventListener('keydown', onKeydown);
321
+ if (dd.__ddPanel) {
322
+ // A portalled panel is no longer inside the container, so a keystroke on
323
+ // an item never bubbles to it. Bound here only, or it would fire twice.
324
+ dd.__ddPanel.addEventListener('keydown', onKeydown);
325
+ // It is also placed once, on open, and a trigger whose box changes after
326
+ // that — a webfont arriving, a longer label — leaves it adrift. Scroll
327
+ // and resize do not see a reflow; this does.
328
+ if (typeof ResizeObserver === 'function') {
329
+ new ResizeObserver(() => {
330
+ if (dd.classList.contains('open')) positionPortalPanel(dd, dd.__ddPanel);
331
+ }).observe(trigger);
332
+ }
333
+ }
221
334
  });
222
335
 
223
336
  if (!_ddGlobalWired) {
@@ -228,5 +341,14 @@ export function wireDropdown(root = document) {
228
341
  const open = document.querySelector('[data-dropdown].open');
229
342
  if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
230
343
  });
344
+ // Viewport coordinates go stale the moment anything scrolls. Capture, so a
345
+ // scroll inside the rail the panel was lifted out of counts too.
346
+ const reposition = () => {
347
+ document.querySelectorAll('[data-dropdown].open').forEach((dd) => {
348
+ if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
349
+ });
350
+ };
351
+ window.addEventListener('scroll', reposition, true);
352
+ window.addEventListener('resize', reposition);
231
353
  }
232
354
  }
@@ -41,8 +41,12 @@
41
41
 
42
42
  /* Panel — the popover surface */
43
43
  .ui-dropdown__panel {
44
+ /* One number for the trigger-to-panel offset, read by both edges here and by
45
+ the portal's JS, so an upward panel cannot drift from a downward one.
46
+ why: docs/specification.md#the-dropdown-panel */
47
+ --ui-dropdown-gap: 9px;
44
48
  position: absolute;
45
- top: calc(100% + 9px);
49
+ top: calc(100% + var(--ui-dropdown-gap));
46
50
  left: 0;
47
51
  min-width: 240px;
48
52
  background: var(--surface-2);
@@ -63,8 +67,33 @@
63
67
  }
64
68
  .ui-dropdown__panel.is-end { left: auto; right: 0; }
65
69
  .ui-dropdown__panel.is-scroll { max-height: 300px; overflow-y: auto; }
70
+
71
+ /* Opening upward. The kit releases its own `top` here so that nothing outside
72
+ the kit has to: an absolute box with both edges pinned is stretched between
73
+ them, and a consumer who set `bottom` and left our `top` standing got a
74
+ fourteen-pixel panel. The entry travel is mirrored too, so the panel arrives
75
+ from the trigger whichever way it opens.
76
+ why: docs/specification.md#the-dropdown-panel */
77
+ .ui-dropdown__panel.is-up {
78
+ top: auto;
79
+ bottom: calc(100% + var(--ui-dropdown-gap));
80
+ transform: translateY(6px);
81
+ }
82
+
66
83
  .ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; transform: translateY(0); }
67
84
 
85
+ /* Portalled panel — wireDropdown() moves it onto <body>, clear of every
86
+ ancestor's overflow and every ancestor's stacking context. The descendant
87
+ selector above stops matching the moment it leaves the trigger's subtree, so
88
+ its open state is a class of its own; the wiring writes the viewport
89
+ coordinates inline, and these `auto`s are what it writes over.
90
+ why: docs/specification.md#the-dropdown-panel */
91
+ .ui-dropdown__panel--portal {
92
+ position: fixed;
93
+ top: auto; right: auto; bottom: auto; left: auto;
94
+ }
95
+ .ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
96
+
68
97
  /* Item row — [icon] [main: label + desc] [badge] [tick] */
69
98
  .ui-dropdown__item {
70
99
  display: flex;