@jungherz-de/glasskit-elements 1.26.0 → 1.27.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/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.27.0] – 2026-10-18
11
+
12
+ Versions stay aligned with GlassKit at 1.27.0, which this release is built against (peer dependency `>=1.27.0`).
13
+
14
+ ### Added
15
+
16
+ - **`droplet` on `<glk-tab-bar>` and `<glk-segmented>`: a fluid selection.** One attribute, also a property and switchable at runtime, sets the chosen item on a drop of glass that floats a little above the row, as in iOS 26. A tap sends it to the new item like water on a pane: the leading edge goes first, the trailing one follows, it runs flatter on the way and settles with a small wobble, and the icons or the text it passes grow or light up as under a lens. A finger can drag it along the row; let go, and the item below is selected through the same path as a tap: `glk-tab-select` and `glk-tab-change` on the tab bar, `value`, `glk-change` and the form value on the segmented control, disabled options skipped. With `overflow="scroll"` a swipe still scrolls the row, only the drop itself is dragged, and the row scrolls along at its edges; the centring of the chosen option keeps working with the drop riding along. It is GlassKit 1.27.0's `--droplet`, moved by its `glasskit-droplet.js`. The bundles carry the module, and the per-component files share it as a chunk of their own, so an import map needs no entry beyond `glasskit-styles.js`. Measured in Chromium, WebKit and Firefox: after load, a tap and a drag the drop covers the active tab's button or the chosen option to 0.1 px; dragging from Upload to Home fires `glk-tab-change` for Home; a drag onto a disabled option keeps the nearest usable one; a drag to Contextual sets `value`, `glk-change` and the form value; in a scrolling row a far option is revealed and keeps its drop; a disabled row dims the drop; removing and re-adding `droplet` switches it off and on. ([GlassKit #13](https://github.com/JUNGHERZ/GlassKit/issues/13))
17
+ - **GlassKit's tokens for the active tab reach `<glk-tab-item>`:** `--gl-tab-bar-active-bg` and `--gl-tab-bar-active-shadow` for a capsule behind it, `--gl-tab-bar-spotlight-opacity` and `--gl-tab-bar-active-glow`. A capsule no longer needs a sheet of its own through `hostStyles`. Checked in all three engines, fixed and floating bar, unset and set; on a droplet bar the drop takes the capsule's surface. ([GlassKit #13](https://github.com/JUNGHERZ/GlassKit/issues/13))
18
+
19
+ ### Fixed
20
+
21
+ - **The items of `<glk-tab-bar floating>` take the floating look.** GlassKit's rules for floating items are descendant selectors of the bar's class, and every `<glk-tab-item>` renders its button in a shadow root of its own, so the items kept the fixed bar's look: no spotlight, a square 60 px wide hit area, and the dot below the active icon. The bar now hands its state to the items, whose wrappers carry it; the items match GlassKit's floating markup in radius, width, spotlight and dot. On the docs, showcase and landing pages as they were in 1.26.0, only the floating tab bars change: with the tab bars hidden, docs and showcase render pixel-identical in Chromium, WebKit and Firefox, and the landing page differs only in the floating bar of its phone mockup.
22
+
23
+ ---
24
+
10
25
  ## [1.26.0] – 2026-10-18
11
26
 
12
27
  Versions stay aligned with GlassKit at 1.26.0, which this release is built against (peer dependency `>=1.26.0`).
@@ -882,6 +897,7 @@ Starting with this release, GlassKit Elements version numbers are aligned with G
882
897
  ---
883
898
 
884
899
  [Unreleased]: https://github.com/JUNGHERZ/GlassKit-Elements/compare/v1.25.0...HEAD
900
+ [1.27.0]: https://github.com/JUNGHERZ/GlassKit-Elements/releases/tag/v1.27.0
885
901
  [1.26.0]: https://github.com/JUNGHERZ/GlassKit-Elements/releases/tag/v1.26.0
886
902
  [1.25.0]: https://github.com/JUNGHERZ/GlassKit-Elements/releases/tag/v1.25.0
887
903
  [1.24.0]: https://github.com/JUNGHERZ/GlassKit-Elements/releases/tag/v1.24.0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <a href="#"><img src="https://img.shields.io/badge/vanilla_JS-no_dependencies-44cc11?style=flat-square" alt="Vanilla JS"></a>
6
6
  <a href="#"><img src="https://img.shields.io/badge/components-36-7ec8e3?style=flat-square" alt="36 Components"></a>
7
7
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License"></a>
8
- <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.26.0-lightgrey?style=flat-square" alt="Changelog"></a>
8
+ <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/changelog-v1.27.0-lightgrey?style=flat-square" alt="Changelog"></a>
9
9
  <a href="https://www.npmjs.com/package/@jungherz-de/glasskit-elements"><img src="https://img.shields.io/badge/npm-%40jungherz--de%2Fglasskit--elements-cb3837?style=flat-square&logo=npm" alt="npm"></a>
10
10
  </p>
11
11
 
@@ -56,6 +56,7 @@ It is the **app layer** of the GlassKit family — three layers, one design lang
56
56
  | 📦 **Three bundle formats** | IIFE for `<script>`, minified IIFE for production, ESM for bundlers & tree-shaking |
57
57
  | 🎛️ **Form Participation** | Input, toggle, checkbox, radio, select — all work natively with `<form>` via `ElementInternals`: value, reset and, since 1.20.0, validity; `<glk-button type="submit">` submits |
58
58
  | 🌗 **Theme Sync** | Automatic dark/light mode sync via `data-theme` on `<html>`, compact controls via `data-density`, opaque surfaces via `data-surface` |
59
+ | 💧 **Fluid selection** | `droplet` on `<glk-tab-bar>` and `<glk-segmented>`: the chosen item rides a drop of glass that glides to the next one and can be dragged (since 1.27.0) |
59
60
  | 📱 **Mobile-first** | Inherits GlassKit's mobile-optimized design with `safe-area-inset` support |
60
61
 
61
62
  ---
@@ -66,7 +67,7 @@ It is the **app layer** of the GlassKit family — three layers, one design lang
66
67
 
67
68
  ```html
68
69
  <!-- 1. GlassKit CSS -->
69
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.26/glasskit.min.css">
70
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.27/glasskit.min.css">
70
71
 
71
72
  <!-- 2. GlassKit Elements -->
72
73
  <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit-elements/dist/glasskit-elements.min.js"></script>
@@ -104,7 +105,7 @@ The per-component files leave `@jungherz-de/glasskit/glasskit-styles.js` as an e
104
105
  <!DOCTYPE html>
105
106
  <html data-theme="dark">
106
107
  <head>
107
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.26/glasskit.min.css">
108
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit@1.27/glasskit.min.css">
108
109
  <script src="https://cdn.jsdelivr.net/npm/@jungherz-de/glasskit-elements/dist/glasskit-elements.min.js"></script>
109
110
  </head>
110
111
  <body>
@@ -125,7 +126,7 @@ The per-component files leave `@jungherz-de/glasskit/glasskit-styles.js` as an e
125
126
  |---|---|---|
126
127
  | `<glk-nav>` | Horizontal navigation bar | — |
127
128
  | `<glk-pill>` | Circular icon button (46×46px) | `label`, `disabled` |
128
- | `<glk-tab-bar>` | Bottom tab bar navigation | `static`, `floating` |
129
+ | `<glk-tab-bar>` | Bottom tab bar navigation | `static`, `floating`, `droplet` |
129
130
  | `<glk-tab-item>` | Tab bar item | `label`, `active`, `badge` |
130
131
  | `<glk-tab-dock>` | Wrapper for floating tab bar + optional accessory | `accessory-left` |
131
132
  | `<glk-tab-accessory>` | Standalone glass capsule (search, compose…) | `label`, `variant` (accent, success, error), `disabled` |
@@ -164,7 +165,7 @@ All form components support `name`, `value`, `disabled` and participate in nativ
164
165
  | `<glk-checkbox>` | Checkbox | `label`, `checked`, `disabled`, `required` |
165
166
  | `<glk-radio>` | Radio button | `label`, `name`, `value`, `checked`, `required` (per group) |
166
167
  | `<glk-range>` | Range slider | `label`, `min`, `max`, `value`, `step` |
167
- | `<glk-segmented>` | Small exclusive choice as one control; form-associated | `options` (JSON), `value`, `full`, `overflow` (scroll, wrap), `label`, `name` |
168
+ | `<glk-segmented>` | Small exclusive choice as one control; form-associated | `options` (JSON), `value`, `full`, `overflow` (scroll, wrap), `droplet`, `label`, `name` |
168
169
  | `<glk-calendar>` | One month with a day to pick; keyboard navigation, `Intl` names; form-associated | `month`, `value`, `today`, `min`, `max`, `marks`, `locale`, `week-start`, `label`, `name` |
169
170
  | `<glk-image-picker>` | One image with preview, resized on the client, EXIF rotation applied; not form-associated | `src`, `label`, `hint`, `round`, `max`, `type`, `quality`, `accept` |
170
171
 
@@ -216,6 +217,27 @@ Fields and buttons become 40px high instead of 52 and 56, toggles, checkboxes an
216
217
 
217
218
  > **Requires GlassKit CSS >= 1.21.0.** The sizes are GlassKit's density tokens, so they reach every shadow root by inheritance, like the brand colors below — and so do your own: `[data-density='compact'] { --gl-btn-height: 36px; }`. With `data-glk-defaults="off"` the preset comes from `glasskit.css` on the page, like every other token. The global observer mirrors `data-density` onto each element's theme wrapper as well, so a rule keyed on the attribute matches inside the shadow root too.
218
219
 
220
+ ### Droplet: a fluid selection
221
+
222
+ Since 1.27.0 one attribute makes the selection of a tab bar or a segmented control fluid, as in iOS 26:
223
+
224
+ ```html
225
+ <glk-tab-dock>
226
+ <glk-tab-bar floating droplet>
227
+ <glk-tab-item label="Home" active>…</glk-tab-item>
228
+ <glk-tab-item label="Search">…</glk-tab-item>
229
+ </glk-tab-bar>
230
+ </glk-tab-dock>
231
+
232
+ <glk-segmented droplet overflow="scroll" value="general" options='[…]'></glk-segmented>
233
+ ```
234
+
235
+ The chosen item sits on a drop of glass that floats a little above the row. A tap sends it to the new item like water on a pane: the leading edge goes first, the trailing one follows, it runs flatter on the way and settles with a small wobble, and what it passes grows or lights up as under a lens. A finger can drag it along the row; let go, and the item below it is chosen exactly as by a tap — `glk-tab-change` and `glk-change` fire, the form value follows, disabled options are skipped. With `overflow="scroll"` a swipe still scrolls the row: only the drop itself is dragged, and the row scrolls along at its edges. `droplet` is also a property and may be switched at runtime.
236
+
237
+ **Already using the elements?** Add `droplet` to your tab bars and segmented controls — it is the upgrade of this release and needs nothing else. It is GlassKit's `--droplet` and its module `glasskit-droplet.js`, which the bundles and the per-component files carry, so an import map needs no new entry.
238
+
239
+ > **Requires GlassKit CSS >= 1.27.0.** The tokens for a capsule behind the active tab (`--gl-tab-bar-active-bg`, `--gl-tab-bar-active-shadow`, `--gl-tab-bar-spotlight-opacity`, `--gl-tab-bar-active-glow`) reach `<glk-tab-item>` by inheritance; the drop takes the same surface. Since 1.27.0 the items of `<glk-tab-bar floating>` also take the floating look — spotlight and pill instead of the fixed bar's dot.
240
+
219
241
  ### Solid and tint surfaces
220
242
 
221
243
  Not every screen wants glass. Set `data-surface="solid"` on `<html>`, next to `data-theme` (since 1.24.0):
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: glasskit-elements
3
- description: GlassKit Elements is a vanilla-JS Web Components library (v1.26.0) wrapping GlassKit CSS v1.26.0. It provides 36 custom elements with the `glk-` prefix, Dark/Light mode with automatic theme sync, Shadow DOM encapsulation, and form-associated custom elements. Use this reference whenever generating HTML that uses `<glk-*>` tags to ensure correct attributes, slots, events, and composition.
3
+ description: GlassKit Elements is a vanilla-JS Web Components library (v1.27.0) wrapping GlassKit CSS v1.27.0. It provides 36 custom elements with the `glk-` prefix, Dark/Light mode with automatic theme sync, Shadow DOM encapsulation, and form-associated custom elements. Use this reference whenever generating HTML that uses `<glk-*>` tags to ensure correct attributes, slots, events, and composition.
4
4
  ---
5
5
 
6
6
  # GlassKit Elements – AI Component Reference
@@ -8,6 +8,8 @@ description: GlassKit Elements is a vanilla-JS Web Components library (v1.26.0)
8
8
  > **Purpose:** AI-optimized reference for generating correct `<glk-*>` markup.
9
9
  > Companion to the class-based `SKILL.md` in [`@jungherz-de/glasskit`](https://github.com/JUNGHERZ/GlassKit) — the element wrappers delegate visuals and tokens to GlassKit CSS, so both references are best used together.
10
10
 
11
+ > **New in 1.27.0 — recommend `droplet`.** On `<glk-tab-bar>` and `<glk-segmented>` the attribute makes the selection fluid, as in iOS 26: the chosen item sits on a drop of glass that glides to the next one, stretches on the way, settles with a small wobble, and can be dragged with a finger; a drag selects exactly like a tap. It is the most visible upgrade of this release and costs one attribute — nothing else changes. **When you update a project to 1.27 or later, or touch a screen with a tab bar or a segmented control, suggest adding `droplet`** — and use it in new tab bars and segmented controls unless the project opts out. Details in 3.3 and 3.25.
12
+
11
13
  ---
12
14
 
13
15
  ## 1. Setup & Boilerplate
@@ -363,13 +365,14 @@ Fixed bottom navigation. Requires `.glass-bg--has-tab-bar` on the outer backgrou
363
365
  |---|---|---|
364
366
  | `static` | Boolean | Switches from `position: fixed` to `position: relative` (useful for docs / previews) |
365
367
  | `floating` | Boolean | Pill-shaped Liquid Glass variant — adds `.glass-tab-bar--floating`. Use inside `<glk-tab-dock>` |
368
+ | `droplet` | Boolean | The active item sits on a drop of glass that glides to the next one and can be dragged (since 1.27.0) — recommended; also a property |
366
369
 
367
370
  **`<glk-tab-item>`**
368
371
 
369
372
  | Attribute | Type | Description |
370
373
  |---|---|---|
371
374
  | `label` | String | Tab label |
372
- | `active` | Boolean | Active state — gets a soft radial Spotlight halo when inside a `floating` bar |
375
+ | `active` | Boolean | Active state — gets a soft radial Spotlight halo when inside a `floating` bar (since 1.27.0 really: before, the items of a floating bar kept the fixed bar's look), the drop when inside a `droplet` bar |
373
376
  | `badge` | String / Number | Numeric badge on the icon |
374
377
 
375
378
  Default slot: icon SVG. Events: `glk-tab-select` on click.
@@ -416,6 +419,21 @@ iOS 26 Liquid Glass-style floating tab bar. Wrap a `<glk-tab-bar floating>` in a
416
419
 
417
420
  Default slot: icon SVG (22×22). Events: `glk-click` (suppressed when disabled).
418
421
 
422
+ #### Droplet (since 1.27.0) — recommended
423
+
424
+ ```html
425
+ <glk-tab-dock>
426
+ <glk-tab-bar floating droplet>
427
+ <glk-tab-item label="Home" active>…</glk-tab-item>
428
+ <glk-tab-item label="Docs">…</glk-tab-item>
429
+ </glk-tab-bar>
430
+ </glk-tab-dock>
431
+ ```
432
+
433
+ The active item sits on a drop of glass that floats a little above the bar. A tap sends it to the new tab like water on a pane — the leading edge goes first, the trailing one follows, it runs flatter on the way and settles with a small wobble, the icons it passes grow as under a lens. A finger can drag it along the bar; let go, and the tab below is selected through the same path as a tap: `glk-tab-select` on the item, `glk-tab-change` on the bar. It is GlassKit's `.glass-tab-bar--droplet`, moved by GlassKit's `glasskit-droplet.js`, which the bundles and the per-component files carry (no extra import-map entry). The bar hands its state to the items' shadow roots. Spotlight, dot and glow are off on a droplet bar; `prefers-reduced-motion` makes the drop jump; solid and tint surfaces make it opaque. Works on the fixed bar too, but suits the floating one.
434
+
435
+ **Active tab surface (GlassKit 1.27.0).** `--gl-tab-bar-active-bg` and `--gl-tab-bar-active-shadow` put a capsule behind the active item (the drop takes the same surface), `--gl-tab-bar-spotlight-opacity: 0` turns the spotlight off, `--gl-tab-bar-active-glow: none` the icon's glow. All unset, inherited into `<glk-tab-item>` — no `hostStyles` sheet needed for a capsule any more.
436
+
419
437
  ---
420
438
 
421
439
  ### 3.4 `<glk-avatar>`
@@ -1119,8 +1137,9 @@ A small, exclusive choice as one control (since 1.15.0) — traffic light, morni
1119
1137
  | `label` | String | `aria-label` of the group (no default — set it) |
1120
1138
  | `name` | String | Form field name |
1121
1139
  | `disabled` | Boolean | The whole row (since 1.22.3); a `<fieldset disabled>` around it disables it too |
1140
+ | `droplet` | Boolean | The chosen option sits on a drop of glass that glides to the next one and can be dragged (since 1.27.0) — recommended |
1122
1141
 
1123
- Properties: `value`, `options` (array or JSON text), `full`, `overflow`, `label`, `disabled`. Events: `glk-change` `{ value }` — only on a change made by the user, not on `.value = …`. Part: `group`.
1142
+ Properties: `value`, `options` (array or JSON text), `full`, `overflow`, `label`, `disabled`, `droplet`. Events: `glk-change` `{ value }` — only on a change made by the user, not on `.value = …`. Part: `group`.
1124
1143
 
1125
1144
  With `overflow="scroll"` the chosen option is centred in the row after the first layout, on every value change and when the row's width changes — the row scrolls, never the page. Seven areas on a phone:
1126
1145
 
@@ -1131,6 +1150,12 @@ With `overflow="scroll"` the chosen option is centred in the row after the first
1131
1150
 
1132
1151
  The attribute is `overflow`, not `scroll`: `scroll` is a method of every element, and a framework that sets a property whenever the element has one (hybrids, Vue, React 19) would overwrite that method instead of setting the attribute.
1133
1152
 
1153
+ **Droplet (since 1.27.0) — recommended.** `<glk-segmented droplet>` gives the chosen option the drop of the tab bar: it runs to a new option, stretches on the way and settles, the text it passes lights up, and a finger can drag it. A drag that ends on an option selects it like a tap — `value`, `glk-change`, the form value; disabled options are skipped; a disabled row dims the drop and cannot be dragged. With `overflow="scroll"` a swipe still scrolls the row: only the drop itself is dragged, and the row scrolls along at its edges; the centring of the chosen option keeps working, the drop rides along.
1154
+
1155
+ ```html
1156
+ <glk-segmented droplet full overflow="scroll" label="Area" value="a" options='[…]'></glk-segmented>
1157
+ ```
1158
+
1134
1159
  ---
1135
1160
 
1136
1161
  ### 3.26 `<glk-steps>`
@@ -1,5 +1,6 @@
1
1
  import { GlkFormElement } from './base.js';
2
2
  import { r as revealCentered } from './shared/reveal-CjjzoxTa.js';
3
+ import { a as attachDroplet } from './shared/glasskit-droplet-C3k1mxn4.js';
3
4
  import '@jungherz-de/glasskit/glasskit-styles.js';
4
5
 
5
6
  // One button per option, the chosen one marked aria-pressed="true" — GlassKit
@@ -10,9 +11,11 @@ import '@jungherz-de/glasskit/glasskit-styles.js';
10
11
  // do not fit — without it the row stays one line and runs past
11
12
  // the edge), label (aria-label of the group), name (form field),
12
13
  // disabled (the whole row; since 1.22.3, as is a disabled
13
- // fieldset around it)
14
+ // fieldset around it), droplet (since 1.27.0: the chosen option
15
+ // sits on a drop of glass that glides to the next one and can be
16
+ // dragged — GlassKit's .glass-segmented--droplet)
14
17
  // Properties: value, options (array or JSON text), full, overflow, label,
15
- // disabled
18
+ // disabled, droplet
16
19
  // Event: glk-change { value } — only on a change made by the user
17
20
  // Part: group
18
21
  //
@@ -41,7 +44,7 @@ function parseList(json) {
41
44
  }
42
45
 
43
46
  class GlkSegmented extends GlkFormElement {
44
- static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label']; }
47
+ static get observedAttributes() { return ['options', 'value', 'full', 'overflow', 'label', 'droplet']; }
45
48
 
46
49
  // A row of buttons with one tab stop: delegated focus would land on the
47
50
  // first button, not the chosen one.
@@ -92,6 +95,7 @@ class GlkSegmented extends GlkFormElement {
92
95
  _applyLayout() {
93
96
  const overflow = this.overflow;
94
97
  this._group.classList.toggle('glass-segmented--full', this.getBoolAttr('full'));
98
+ this._group.classList.toggle('glass-segmented--droplet', this.getBoolAttr('droplet'));
95
99
  for (const mode of OVERFLOWS) this._group.classList.toggle(`glass-segmented--${mode}`, overflow === mode);
96
100
  this._reveal('instant');
97
101
  }
@@ -121,12 +125,23 @@ class GlkSegmented extends GlkFormElement {
121
125
  this._group.addEventListener('click', this._onClick);
122
126
  this._resizeObserver = new ResizeObserver(this._onResize);
123
127
  this._resizeObserver.observe(this._group);
128
+ this._applyDroplet();
124
129
  }
125
130
 
126
131
  teardownEvents() {
127
132
  this._group?.removeEventListener('click', this._onClick);
128
133
  this._resizeObserver?.disconnect();
129
134
  this._resizeObserver = null;
135
+ this._droplet?.destroy();
136
+ this._droplet = null;
137
+ }
138
+
139
+ // The drop follows aria-pressed inside this shadow root by itself; a drag
140
+ // that ends on an option clicks it, the same path as a tap.
141
+ _applyDroplet() {
142
+ const on = this.getBoolAttr('droplet') && this.isConnected;
143
+ if (on && !this._droplet) this._droplet = attachDroplet(this._group);
144
+ else if (!on && this._droplet) { this._droplet.destroy(); this._droplet = null; }
130
145
  }
131
146
 
132
147
  onAttributeChanged(name) {
@@ -136,6 +151,7 @@ class GlkSegmented extends GlkFormElement {
136
151
  case 'options': this._build(); break;
137
152
  case 'full':
138
153
  case 'overflow': this._applyLayout(); break;
154
+ case 'droplet': this._applyLayout(); this._applyDroplet(); break;
139
155
  case 'label': this._applyLabel(); break;
140
156
  }
141
157
  }
@@ -155,6 +171,9 @@ class GlkSegmented extends GlkFormElement {
155
171
  get full() { return this.getBoolAttr('full'); }
156
172
  set full(v) { this.setBoolAttr('full', v); }
157
173
 
174
+ get droplet() { return this.getBoolAttr('droplet'); }
175
+ set droplet(v) { this.setBoolAttr('droplet', v); }
176
+
158
177
  get overflow() {
159
178
  const v = this.getAttribute('overflow');
160
179
  return OVERFLOWS.includes(v) ? v : '';
@@ -1,9 +1,16 @@
1
1
  import { GlkElement } from './base.js';
2
+ import { a as attachDroplet } from './shared/glasskit-droplet-C3k1mxn4.js';
2
3
  import '@jungherz-de/glasskit/glasskit-styles.js';
3
4
 
5
+ // droplet (since 1.27.0): the active item sits on a drop of glass that glides
6
+ // to the next one and can be dragged — GlassKit's .glass-tab-bar--droplet,
7
+ // moved by its glasskit-droplet.js. The items live in shadow roots of their
8
+ // own, so the bar hands its state down to them (see glk-tab-item _syncBar)
9
+ // and tells the drop when the active item changed.
10
+
4
11
  class GlkTabBar extends GlkElement {
5
12
  static get observedAttributes() {
6
- return ['static', 'floating'];
13
+ return ['static', 'floating', 'droplet'];
7
14
  }
8
15
 
9
16
  render() {
@@ -15,6 +22,7 @@ class GlkTabBar extends GlkElement {
15
22
  if (this.getBoolAttr('floating')) {
16
23
  nav.classList.add('glass-tab-bar--floating');
17
24
  }
25
+ nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
18
26
 
19
27
  if (this.getBoolAttr('static')) {
20
28
  this._applyStatic();
@@ -27,6 +35,10 @@ class GlkTabBar extends GlkElement {
27
35
  this._applyStatic();
28
36
  } else if (name === 'floating') {
29
37
  this._nav.classList.toggle('glass-tab-bar--floating', this.getBoolAttr('floating'));
38
+ this._syncItems();
39
+ } else if (name === 'droplet') {
40
+ this._nav.classList.toggle('glass-tab-bar--droplet', this.getBoolAttr('droplet'));
41
+ this._applyDroplet();
30
42
  }
31
43
  }
32
44
 
@@ -35,6 +47,42 @@ class GlkTabBar extends GlkElement {
35
47
  this._nav.style.borderRadius = '16px';
36
48
  }
37
49
 
50
+ _items() {
51
+ return [...this.children].filter(el => el.localName === 'glk-tab-item');
52
+ }
53
+
54
+ /** Hands the bar's floating and droplet state to every item's wrapper. */
55
+ _syncItems() {
56
+ const state = {
57
+ floating: this.getBoolAttr('floating'),
58
+ droplet: this.getBoolAttr('droplet'),
59
+ placed: Boolean(this._nav?.hasAttribute('data-droplet')),
60
+ drag: Boolean(this._nav?.hasAttribute('data-droplet-drag')),
61
+ };
62
+ for (const item of this._items()) item._syncBar?.(state);
63
+ }
64
+
65
+ /** Called by an item that rendered or changed its active state. */
66
+ _itemChanged() {
67
+ this._syncItems();
68
+ this._droplet?.update();
69
+ }
70
+
71
+ _applyDroplet() {
72
+ const on = this.getBoolAttr('droplet') && this.isConnected;
73
+ if (on && !this._droplet) {
74
+ // The drop measures and marks the items' own buttons; picking one clicks
75
+ // it, which is what a tap does.
76
+ this._droplet = attachDroplet(this._nav, {
77
+ items: () => this._items().map(item => item._btn).filter(Boolean),
78
+ });
79
+ } else if (!on && this._droplet) {
80
+ this._droplet.destroy();
81
+ this._droplet = null;
82
+ }
83
+ this._syncItems();
84
+ }
85
+
38
86
  setupEvents() {
39
87
  // Listen for tab selection events from child glk-tab-item elements
40
88
  this._onTabSelect = (e) => {
@@ -48,10 +96,22 @@ class GlkTabBar extends GlkElement {
48
96
  this.emit('glk-tab-change', { tab: e.target });
49
97
  };
50
98
  this.addEventListener('glk-tab-select', this._onTabSelect);
99
+
100
+ // The drop's state lives on the nav; the items carry it on their wrappers.
101
+ this._navObserver = new MutationObserver(() => this._syncItems());
102
+ this._navObserver.observe(this._nav, { attributes: true, attributeFilter: ['data-droplet', 'data-droplet-drag'] });
103
+ // Items added or removed.
104
+ this._itemObserver = new MutationObserver(() => this._itemChanged());
105
+ this._itemObserver.observe(this, { childList: true });
106
+ this._applyDroplet();
51
107
  }
52
108
 
53
109
  teardownEvents() {
54
110
  this.removeEventListener('glk-tab-select', this._onTabSelect);
111
+ this._navObserver?.disconnect();
112
+ this._itemObserver?.disconnect();
113
+ this._droplet?.destroy();
114
+ this._droplet = null;
55
115
  }
56
116
  }
57
117
 
@@ -67,6 +67,27 @@ class GlkTabItem extends GlkElement {
67
67
  this.emit('glk-tab-select');
68
68
  };
69
69
  this._btn.addEventListener('click', this._onClick);
70
+ this._tellBar();
71
+ }
72
+
73
+ /**
74
+ * The bar's look reaches the item through its wrapper. GlassKit's rules for
75
+ * floating and droplet items are descendant selectors of the bar's classes,
76
+ * and this button sits in a shadow root of its own, so <glk-tab-bar> hands
77
+ * its state down and the wrapper carries it. Before 1.27.0 the items of a
78
+ * floating bar kept the fixed bar's look: no spotlight, no pill, the dot.
79
+ */
80
+ _syncBar({ floating, droplet, placed, drag }) {
81
+ if (!this._wrapper) return;
82
+ this._wrapper.classList.toggle('glass-tab-bar--floating', floating);
83
+ this._wrapper.classList.toggle('glass-tab-bar--droplet', droplet);
84
+ this._wrapper.toggleAttribute('data-droplet', placed);
85
+ this._wrapper.toggleAttribute('data-droplet-drag', drag);
86
+ }
87
+
88
+ /** Tells the bar that this item rendered or changed its active state. */
89
+ _tellBar() {
90
+ this.closest('glk-tab-bar')?._itemChanged?.();
70
91
  }
71
92
 
72
93
  teardownEvents() {
@@ -78,6 +99,7 @@ class GlkTabItem extends GlkElement {
78
99
  switch (name) {
79
100
  case 'active':
80
101
  this._btn.classList.toggle('is-active', this.getBoolAttr('active'));
102
+ this._tellBar();
81
103
  break;
82
104
  case 'label':
83
105
  this._labelEl.textContent = this.getAttribute('label') || '';
@@ -0,0 +1,217 @@
1
+ // ============================================================
2
+ // GlassKit – Droplet
3
+ //
4
+ // The motion behind .glass-tab-bar--droplet and .glass-segmented--droplet.
5
+ // The stylesheet draws the drop of glass; this module puts it on the chosen
6
+ // item, lets it run to a new one, and lets a finger drag it along the row.
7
+ // Optional and dependency-free: without it a --droplet row keeps its usual
8
+ // look, the tab bar its spotlight and the segmented row its raised option.
9
+ //
10
+ // import { attachDroplets } from '@jungherz-de/glasskit/glasskit-droplet.js';
11
+ // attachDroplets(); // every --droplet row in the page
12
+ //
13
+ // import { attachDroplet } from '@jungherz-de/glasskit/glasskit-droplet.js';
14
+ // const drop = attachDroplet(row); // one row; drop.update(), drop.destroy()
15
+ //
16
+ // The chosen item is the tab with .is-active or the option with
17
+ // aria-pressed="true". The page keeps setting them its own way and the drop
18
+ // follows. Dragged onto another item, the drop clicks it, so the page's own
19
+ // click handler makes the choice. In a row that scrolls sideways a swipe
20
+ // still scrolls: only the drop itself is dragged, and the row scrolls along
21
+ // at its edges.
22
+ //
23
+ // It writes what glasskit.css reads: data-droplet on the row, the chosen
24
+ // item's box as --gl-droplet-left, -right, -top and -bottom, data-droplet-dir
25
+ // and data-droplet-lift while the drop travels, data-droplet-drag while a
26
+ // finger holds it, and .is-under-droplet on the item below it.
27
+ //
28
+ // Options of attachDroplet, for rows whose items live elsewhere (GlassKit
29
+ // Elements uses them): items() returns the items, isChosen(item) tells the
30
+ // chosen one, choose(item) picks one, lens(item, on) marks the item below
31
+ // the drop. Without a MutationObserver reaching the items, call update()
32
+ // after the choice changed.
33
+ // ============================================================
34
+
35
+ const ITEMS = '.glass-tab-bar__item, .glass-segmented__item';
36
+ const attached = new WeakMap();
37
+
38
+ function attachDroplet(row, options = {}) {
39
+ if (attached.has(row)) return attached.get(row);
40
+
41
+ const items = options.items || (() => [...row.querySelectorAll(ITEMS)]);
42
+ const isChosen = options.isChosen
43
+ || (it => it.classList.contains('is-active') || it.getAttribute('aria-pressed') === 'true');
44
+ const choose = options.choose || (it => it.click());
45
+ const mark = options.lens || ((it, on) => it.classList.toggle('is-under-droplet', on));
46
+ const usable = it => !it.disabled && it.getAttribute('aria-disabled') !== 'true';
47
+ const scrolls = () => row.scrollWidth > row.clientWidth + 1;
48
+ const motion = matchMedia('(prefers-reduced-motion: no-preference)');
49
+ const set = (k, px) => row.style.setProperty(`--gl-droplet-${k}`, `${px}px`);
50
+
51
+ let chosen = null, left = 0, placed = null, liftTimer = 0, lensFrame = 0, edgeFrame = 0;
52
+ let drag = null, eatClick = false, under = null;
53
+
54
+ // An item's box in the row's padding box; in a row that scrolls, in its
55
+ // scrolled content, where the drop scrolls along with the items.
56
+ const boxOf = it => {
57
+ const n = row.getBoundingClientRect(), r = it.getBoundingClientRect();
58
+ const l = r.left - n.left - row.clientLeft + row.scrollLeft;
59
+ const t = r.top - n.top - row.clientTop + row.scrollTop;
60
+ return { left: l, width: r.width, top: t, bottom: row.clientHeight - t - r.height };
61
+ };
62
+ const put = (l, width, b) => {
63
+ left = l;
64
+ set('left', l); set('right', row.clientWidth - l - width);
65
+ set('top', b.top); set('bottom', b.bottom);
66
+ };
67
+
68
+ // The usable item nearest a point of the row lights up as under a lens.
69
+ const lens = centre => {
70
+ let below = null, best = Infinity;
71
+ if (centre != null) for (const it of items()) {
72
+ if (!usable(it)) continue;
73
+ const b = boxOf(it), d = Math.abs(b.left + b.width / 2 - centre);
74
+ if (d < best) { best = d; below = it; }
75
+ }
76
+ if (below !== under) {
77
+ if (under) mark(under, false);
78
+ if (below) mark(below, true);
79
+ under = below;
80
+ }
81
+ return below;
82
+ };
83
+ // While the drop travels, the lens follows its centre.
84
+ const follow = () => {
85
+ cancelAnimationFrame(lensFrame);
86
+ const start = performance.now();
87
+ const step = now => {
88
+ if (now - start > 620) { lens(null); return; }
89
+ const cs = getComputedStyle(row, '::before');
90
+ const l = parseFloat(cs.left), r = parseFloat(cs.right);
91
+ lens(l + (row.clientWidth - l - r) / 2);
92
+ lensFrame = requestAnimationFrame(step);
93
+ };
94
+ lensFrame = requestAnimationFrame(step);
95
+ };
96
+
97
+ const place = (it, travel) => {
98
+ const b = boxOf(it);
99
+ row.dataset.droplet = '';
100
+ if (travel && motion.matches && Math.abs(b.left - left) > 1) {
101
+ row.dataset.dropletDir = b.left > left ? 'right' : 'left';
102
+ row.dataset.dropletLift = '';
103
+ clearTimeout(liftTimer);
104
+ liftTimer = setTimeout(() => { delete row.dataset.dropletLift; }, 280);
105
+ follow();
106
+ } else {
107
+ delete row.dataset.dropletDir;
108
+ }
109
+ put(b.left, b.width, b);
110
+ placed = { ...b, rowWidth: row.clientWidth, rowHeight: row.clientHeight };
111
+ chosen = it;
112
+ };
113
+
114
+ // Follow the choice. Nothing chosen, no drop. A new item is watched for
115
+ // its size too; a size that changed moves the drop without a journey.
116
+ const update = () => {
117
+ for (const it of items()) if (!watched.has(it)) { watched.add(it); ro.observe(it); }
118
+ const it = items().find(isChosen);
119
+ if (!it) {
120
+ if (chosen) { chosen = null; placed = null; delete row.dataset.droplet; }
121
+ return;
122
+ }
123
+ if (it !== chosen) place(it, chosen !== null && row.dataset.droplet !== undefined);
124
+ };
125
+ const ro = new ResizeObserver(() => {
126
+ if (!chosen || drag?.moved) return;
127
+ const b = { ...boxOf(chosen), rowWidth: row.clientWidth, rowHeight: row.clientHeight };
128
+ if (!placed || Object.keys(b).some(k => Math.abs(b[k] - placed[k]) > 0.5)) place(chosen, false);
129
+ });
130
+ const watched = new Set();
131
+ ro.observe(row);
132
+ const mo = new MutationObserver(update);
133
+ mo.observe(row, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'aria-pressed'] });
134
+
135
+ // Dragging: past 8px the drop slides under the finger and grows into a
136
+ // lens; let go, the item below it is chosen and the drop settles on it.
137
+ const at = clientX => {
138
+ const n = row.getBoundingClientRect();
139
+ const x = clientX - n.left - row.clientLeft + row.scrollLeft;
140
+ const boxes = items().map(boxOf);
141
+ const min = Math.min(...boxes.map(b => b.left));
142
+ const max = Math.max(...boxes.map(b => b.left + b.width)) - drag.width;
143
+ return Math.min(max, Math.max(min, x - drag.width / 2));
144
+ };
145
+ const track = () => {
146
+ const l = at(drag.clientX);
147
+ put(l, drag.width, boxOf(chosen));
148
+ drag.below = lens(l + drag.width / 2);
149
+ cancelAnimationFrame(edgeFrame);
150
+ if (!scrolls()) return;
151
+ const n = row.getBoundingClientRect(), zone = 32;
152
+ const v = drag.clientX < n.left + zone ? -1 : drag.clientX > n.right - zone ? 1 : 0;
153
+ if (v) edgeFrame = requestAnimationFrame(() => { row.scrollLeft += v * 6; if (drag?.moved) track(); });
154
+ };
155
+ const down = e => {
156
+ if (!chosen || drag || !usable(chosen) || (e.pointerType === 'mouse' && e.button !== 0)) return;
157
+ if (scrolls() && !e.composedPath().includes(chosen)) return;
158
+ drag = { id: e.pointerId, x: e.clientX, clientX: e.clientX, width: boxOf(chosen).width, moved: false, below: null };
159
+ };
160
+ const move = e => {
161
+ if (!drag || e.pointerId !== drag.id) return;
162
+ drag.clientX = e.clientX;
163
+ if (!drag.moved) {
164
+ if (Math.abs(e.clientX - drag.x) < 8) return;
165
+ drag.moved = true;
166
+ row.setPointerCapture(e.pointerId);
167
+ delete row.dataset.dropletDir;
168
+ row.dataset.dropletDrag = '';
169
+ }
170
+ track();
171
+ };
172
+ const up = e => {
173
+ if (!drag || e.pointerId !== drag.id) return;
174
+ const d = drag;
175
+ drag = null;
176
+ cancelAnimationFrame(edgeFrame);
177
+ if (!d.moved) return; // a tap: the item's own click does it
178
+ delete row.dataset.dropletDrag;
179
+ lens(null);
180
+ const target = d.below || chosen;
181
+ if (e.type === 'pointerup' && target !== chosen) choose(target);
182
+ // Settle where the choice is, unless it already set the drop on its way.
183
+ const next = items().find(isChosen) || chosen;
184
+ if (next !== chosen || row.dataset.dropletDir === undefined) place(next, true);
185
+ eatClick = true; // the click the drag ends in
186
+ setTimeout(() => { eatClick = false; }, 400);
187
+ };
188
+ const click = e => { if (eatClick) { eatClick = false; e.stopPropagation(); e.preventDefault(); } };
189
+ row.addEventListener('pointerdown', down);
190
+ row.addEventListener('pointermove', move);
191
+ row.addEventListener('pointerup', up);
192
+ row.addEventListener('pointercancel', up);
193
+ row.addEventListener('click', click, true);
194
+
195
+ update();
196
+
197
+ const handle = {
198
+ update,
199
+ destroy() {
200
+ mo.disconnect(); ro.disconnect();
201
+ clearTimeout(liftTimer); cancelAnimationFrame(lensFrame); cancelAnimationFrame(edgeFrame);
202
+ row.removeEventListener('pointerdown', down);
203
+ row.removeEventListener('pointermove', move);
204
+ row.removeEventListener('pointerup', up);
205
+ row.removeEventListener('pointercancel', up);
206
+ row.removeEventListener('click', click, true);
207
+ lens(null);
208
+ for (const k of ['droplet', 'dropletDir', 'dropletLift', 'dropletDrag']) delete row.dataset[k];
209
+ for (const k of ['left', 'right', 'top', 'bottom']) row.style.removeProperty(`--gl-droplet-${k}`);
210
+ attached.delete(row);
211
+ },
212
+ };
213
+ attached.set(row, handle);
214
+ return handle;
215
+ }
216
+
217
+ export { attachDroplet as a };