";
+ inherits: false;
+ initial-value: 1;
+}
+.pointer-arrow {
+ display: block;
+ font-size: clamp(1rem, 3vw, 2rem);
+ line-height: 1;
+ --arrow-angle: atan2(calc(var(--flux-pointer-y-raw-px, 0) - var(--flux-size-y, 0) / 2), calc(var(--flux-pointer-x-raw-px, 1) - var(--flux-size-x, 0) / 2));
+ /* Smooth direction components so crossing ±180deg does not cause a full spin. */
+ --arrow-sin: sin(var(--arrow-angle));
+ --arrow-cos: cos(var(--arrow-angle));
+ transform: rotate(atan2(var(--arrow-sin), var(--arrow-cos)));
+ transition: --arrow-sin 0.25s ease-out, --arrow-cos 0.25s ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pointer-arrow {
+ transition: none;
+ }
+}
+:root {
+ --flair-space-1: 0.25rem;
+ --flair-space-2: 0.5rem;
+ --flair-space-3: 0.75rem;
+ --flair-space-4: 1rem;
+ --flair-space-6: 1.5rem;
+ --flair-space-8: 2rem;
+ --flair-color-text: #111;
+ --flair-color-muted: #525252;
+ --flair-color-surface: #fff;
+ --flair-color-surface-disabled: #eee;
+ --flair-color-border: #767676;
+ --flair-color-accent: #111;
+ --flair-color-on-accent: #fff;
+ --flair-color-danger: #111;
+ --flair-font-body: "Ubuntu", sans-serif;
+ --flair-font-size: 1rem;
+ --flair-line-height: 1.5;
+ --flair-font-weight: 400;
+ --flair-label-weight: 500;
+ --flair-border-width: 1px;
+ --flair-radius: 0;
+ --flair-focus-width: 3px;
+ --flair-focus-offset: 2px;
+ --flair-control-min-height: 2.75rem;
+ --flair-choice-size: 1.25rem;
+ --flair-textarea-min-height: 8rem;
+ --flair-field-min-width: 14rem;
+ --flair-font-heading: "Ubuntu Condensed", sans-serif;
+ --flair-font-mono: "Ubuntu Mono", monospace;
+ --flair-heading-weight: 400;
+ --flair-font-body: system-ui, sans-serif;
+}
+
+:where(h1, h2, h3, h4, h5, h6) {
+ margin-block: var(--flair-space-6) var(--flair-space-3);
+}
+
+:where(h1) {
+ font-size: var(--flair-heading-1, 2.5rem);
+}
+
+:where(h2) {
+ font-size: var(--flair-heading-2, 2rem);
+}
+
+:where(h3) {
+ font-size: var(--flair-heading-3, 1.5rem);
+}
+
+:where(h4) {
+ font-size: var(--flair-heading-4, 1.25rem);
+}
+
+:where(h5, h6) {
+ font-size: var(--flair-font-size);
+}
+
+:where(p, ul, ol, dl, blockquote, figure) {
+ margin: 0;
+ margin-block-end: var(--flair-space-4);
+ line-height: inherit;
+}
+
+:where(ul, ol) {
+ padding-inline-start: var(--flair-space-6);
+}
+
+:where(li + li) {
+ margin-block-start: var(--flair-space-2);
+}
+
+:where(blockquote) {
+ padding-inline-start: var(--flair-space-4);
+ border-inline-start: var(--flair-quote-border-width, 4px) solid var(--flair-color-accent);
+ color: var(--flair-color-muted);
+}
+
+:where(small, figcaption) {
+ font-size: var(--flair-small-size, 0.875rem);
+ color: var(--flair-color-muted);
+}
+
+:where(mark) {
+ background: var(--flair-selected-background, var(--flair-color-surface-disabled));
+ color: var(--flair-selected-text, var(--flair-color-text));
+}
+
+:where(hr) {
+ border: 0;
+ border-block-start: var(--flair-border-width) solid var(--flair-color-border);
+ margin-block: var(--flair-space-6);
+}
+
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+body {
+ background: var(--flair-color-surface);
+ color: var(--flair-color-text);
+}
+
+.wordmark {
+ color: inherit;
+ font-size: 2.8rem;
+ font-weight: 850;
+ letter-spacing: -0.08em;
+ text-decoration: none;
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+}
+
+.wordmark span {
+ font-size: 0.7rem;
+ letter-spacing: 0.1em;
+}
+
+.site-layout {
+ --flair-sidebar-width: 13rem;
+ --flair-content-min: 22rem;
+ --flair-layout-gap: 3rem;
+ --flair-page-padding-block: 3rem;
+}
+@media (min-width: 70rem) {
+ .site-layout > aside {
+ position: sticky;
+ top: var(--flair-sticky-offset, var(--flair-space-6));
+ align-self: flex-start;
+ }
+}
+
+.sidebar-note {
+ font-size: 0.8rem;
+ color: var(--flair-color-muted);
+ margin-block: 2rem;
+}
+
+.demo {
+ --flair-reveal-progress: var(--flux-first-visible, 1);
+ --flair-surface-padding: clamp(1.25rem, 3vw, 2.5rem);
+ --flair-card-title-size: 1.8rem;
+ margin-top: 3rem;
+ scroll-margin-top: 1rem;
+}
+
+.split, .demo-grid {
+ --flair-grid-columns: 2;
+ --flair-grid-min: 17rem;
+ align-items: start;
+}
+
+.demo-copy p {
+ max-width: 45ch;
+}
+
+.demo h2 {
+ --flair-heading-2: 1.8rem;
+}
+
+.demo h3 {
+ --flair-heading-3: 1.2rem;
+ --flair-card-title-size: 1.2rem;
+}
+
+.source {
+ margin-top: 0.6rem;
+ font-size: 0.85rem;
+}
+
+pre {
+ max-height: 30rem;
+ overflow: auto;
+}
+
+.sortable-list {
+ overflow-anchor: none;
+ --flair-list-padding: var(--flair-space-3);
+ margin-block-end: var(--flair-space-6);
+}
+
+.complete .item-text {
+ text-decoration: line-through;
+ color: var(--flair-color-muted);
+}
+
+.item-actions {
+ --flair-button-padding-inline: var(--flair-space-2);
+}
+
+.drag-handle {
+ width: 1.25rem;
+ height: 2rem;
+ cursor: grab;
+ touch-action: none;
+ display: grid;
+ place-items: center;
+}
+
+.drag-handle::before {
+ content: "⠿";
+ font-size: 1.5rem;
+}
+
+.drag-form {
+ margin: 0;
+}
+
+.list-demo .fields {
+ margin-top: 1rem;
+}
+
+.counter-sum {
+ --flair-value-size: 4rem;
+ margin-block: var(--flair-space-8);
+}
+
+.search-results {
+ --flair-list-padding: var(--flair-space-3);
+}
+
+.chapter {
+ border-block: var(--flair-border-width) solid var(--flair-color-border);
+ padding-block: var(--flair-space-4);
+ margin-block: var(--flair-space-6);
+}
+
+.emphasised {
+ color: var(--flair-color-on-accent);
+ background: var(--flair-color-accent);
+}
+
+.next-steps {
+ padding-block: var(--flair-space-8);
+}
+
+@media (min-width: 70rem) {
+ #single-counter {
+ --flair-metric-align: end;
+ }
+}
+@media print {
+ .site-nav, .source, .site-header > p {
+ display: none;
+ }
+ .demo {
+ opacity: 1;
+ translate: none;
+ break-inside: avoid;
+ }
+}
+.board-column {
+ min-height: 10rem;
+ border: 1px dashed var(--flair-color-border);
+ padding: 0.75rem;
+}
+
+.city-dialog dl {
+ --flair-data-min: 100%;
+}
+
+.city-results {
+ --flair-results-max-height: 22rem;
+}
diff --git a/examples/cities.php b/examples/cities.php
new file mode 100644
index 0000000..82d1b95
--- /dev/null
+++ b/examples/cities.php
@@ -0,0 +1,25 @@
+
+ stripos($city['name'] . ' ' . $city['ascii'] . ' ' . $city['country'], $query) !== false
+ ));
+}
+
+function selectedCity(string $id): ?array {
+ foreach(cities() as $city) {
+ if($city['id'] === $id) return $city;
+ }
+ return null;
+}
+
+function cityUrl(string $id, string $query, string $returnPage): string {
+ return '?' . http_build_query(['page' => $returnPage, 'q' => $query, 'city' => $id]);
+}
diff --git a/examples/components/arrows.php b/examples/components/arrows.php
new file mode 100644
index 0000000..a877a7e
--- /dev/null
+++ b/examples/components/arrows.php
@@ -0,0 +1,12 @@
+
+
+
Follow the pointer
+
A PHP for loop outputs 40 span elements. Each has data-flux="flux-pointer flux-size", exposing its dimensions and the pointer position relative to it as CSS variables.
+
CSS subtracts half the element's width and height from the raw pointer coordinates, then uses atan2() to calculate the angle from its centre to the pointer. Transitions on the sin() and cos() components smooth the rotation without a full spin when the angle crosses 180 degrees.
+
+
+
+ →
+
+
+
diff --git a/examples/components/autosave.php b/examples/components/autosave.php
new file mode 100644
index 0000000..621205a
--- /dev/null
+++ b/examples/components/autosave.php
@@ -0,0 +1,11 @@
+
+ Saving on change
+ The Save button has data-flux="autosave". Flux hides the button and submits its form on a change event, which for this textarea happens when you edit the note and leave the field.
+ PHP stores the note in the session. The preview has data-flux="update-inner", so Flux replaces its contents from the returned page. Without JavaScript, the Save button submits the same form.
+
+ Saved in this session = h($_SESSION['note'] ?? 'No note has been saved.') ?>
+
diff --git a/examples/components/board.php b/examples/components/board.php
new file mode 100644
index 0000000..64f08db
--- /dev/null
+++ b/examples/components/board.php
@@ -0,0 +1,27 @@
+
+ Kanban board
+ Each card has a form with parent and order inputs. Adding data-flux="drag-order" lets Flux fill them in when a card is dropped. Each destination list supplies its identifier through data-flux-drag-parent, and data-flux-drag-handle supplies the handle text.
+ PHP moves the card to the submitted column and position in the session. The board's data-flux="update-inner" replaces its contents from the response, so dragging uses the same server action as the destination and position controls available without JavaScript.
+
+
+ New Kanban task
+ Add task to Ready
+
+
+ 'Ready', 'doing' => 'In progress', 'finished' => 'Finished'] as $column => $title): ?>
+
+
+
+
diff --git a/examples/components/calendar.php b/examples/components/calendar.php
new file mode 100644
index 0000000..6263a41
--- /dev/null
+++ b/examples/components/calendar.php
@@ -0,0 +1,14 @@
+
+ Calendar progress
+ The container uses data-flux="flux-date" to expose the local date and progress through the year, month, week and day as CSS variables. CSS uses the progress values with scaleX() to set each bar's length and the date variables to generate the labels.
+ Date names follow the nearest HTML lang attribute, demonstrated by the French labels below. Values use the device's time zone, and weeks start on Monday. The connection (flux-date@#calendar-connection) copies the variables to another element so it can display the same date.
+
+
+ 'Year', 'month' => 'Month', 'week' => 'Week', 'day' => 'Day'] as $period => $label): ?>
+ = $label ?>
+
+
+
This date receives the same values through a connection.
+
+ --flux-date-weekday is Monday 1 to Sunday 7. The day-progress property is named --flux-day-scalar.
+
diff --git a/examples/components/city-dialog.php b/examples/components/city-dialog.php
new file mode 100644
index 0000000..b7470c2
--- /dev/null
+++ b/examples/components/city-dialog.php
@@ -0,0 +1,13 @@
+
+
+
+
+ = $city ? 'Selected city' : 'City not found' ?>
+
+ You selected = h($city['name']) ?>, = h($city['country']) ?> .
+
City = h($city['name']) ?>
Country or territory = h($city['country']) ?>
Time zone = h($city['timezone']) ?>
+ The selected city is not in this example's dataset.
+ Close
+
+
+
diff --git a/examples/components/city-results.php b/examples/components/city-results.php
new file mode 100644
index 0000000..5842125
--- /dev/null
+++ b/examples/components/city-results.php
@@ -0,0 +1,21 @@
+
+
+
+
Enter a city or country name to search.
+
+
= count($matches) ?> = count($matches) === 1 ? 'city' : 'cities' ?> matching “= h($query) ?>”.
+
+
+
No cities matched. Enter another city or country name.
+
+
City data: GeoNames , CC BY 4.0 .
+
diff --git a/examples/components/clock.php b/examples/components/clock.php
new file mode 100644
index 0000000..fd591d1
--- /dev/null
+++ b/examples/components/clock.php
@@ -0,0 +1,17 @@
+
+ Analogue clock
+
The example has data-flux="flux-time flux-date", which exposes the device's local time and date as CSS variables. The clock-stage container has data-flux="flux-pointer", so the pointer coordinates and tilt are measured within the clock's display area.
+
The clock's hands are HTML elements. CSS multiplies the time variables by 360deg to rotate them, and uses the pointer coordinates to tilt the face. CSS counters and date variables provide the text below the clock, so the display needs no requests to the server.
+
Time and date examples
+
+
+
diff --git a/examples/components/counter.php b/examples/components/counter.php
new file mode 100644
index 0000000..abc9f7c
--- /dev/null
+++ b/examples/components/counter.php
@@ -0,0 +1,12 @@
+
+ Increment/decrement counter
+
Each button submits an ordinary HTML form with a do value of minus or plus. PHP changes the number stored in the session and renders the form with its new value.
+
Adding data-flux="auto" to the form makes Flux submit it in the background and replace it from the returned page. The same PHP handles a normal page submission when JavaScript is unavailable.
+
+
+ = $_SESSION['single-counter'] ?? 0 ?>
+ Decrement Increment
+
+
+ Form examples
+
diff --git a/examples/components/counters.php b/examples/components/counters.php
new file mode 100644
index 0000000..9c3dc38
--- /dev/null
+++ b/examples/components/counters.php
@@ -0,0 +1,16 @@
+
+ Multiple forms: adding two numbers
+ Each counter is a separate POST form. PHP uses a hidden counter input to identify the number to change, stores both numbers in the session, and calculates their sum when rendering the page.
+ The first form uses data-flux="auto"; the second uses the equivalent empty data-flux attribute. Flux submits them in the background and refreshes the forms from the response. The total has its own data-flux="update" target, so it updates too. The scratchpad is outside these targets and retains its text.
+
+
+
>
+
+ Counter = strtoupper($key) ?> = $_SESSION['counters'][$key] ?? 0 ?>
+ Subtract one Add one
+
+
+
+ A + B = = array_sum($_SESSION['counters'] ?? []) ?>
+ Your scratchpad
+
diff --git a/examples/components/drag-order.php b/examples/components/drag-order.php
new file mode 100644
index 0000000..229de1f
--- /dev/null
+++ b/examples/components/drag-order.php
@@ -0,0 +1,21 @@
+
+ Drag-order: horizontal and vertical
+ Each item contains a form with an order input and a Move button. Adding data-flux="drag-order" to the item lets Flux set its position and submit the form when it is dropped. The list's data-flux-drag-axis restricts dragging to x or y.
+ PHP saves the new order in the session, and each list's update-inner container receives the resulting HTML. The position controls remain usable without JavaScript, so both ways of ordering items use the same form fields and server action.
+ 'x', 'vertical' => 'y'] as $direction => $axis): $listName = 'order-' . $direction; ?>
+
+
= ucfirst($direction) ?>
+
+
+
+
diff --git a/examples/components/gauge.php b/examples/components/gauge.php
new file mode 100644
index 0000000..3150bd7
--- /dev/null
+++ b/examples/components/gauge.php
@@ -0,0 +1,9 @@
+
+
+
Radial gauge
+
The container uses data-flux="flux-range" to expose the range input's value as --flux-range, normalised from zero to one.
+
CSS multiplies that value by 270deg to draw the filled arc with a conic gradient. The same variable sets the colour, percentage text and redline state, so all parts of the gauge follow the native input without separate event handlers.
+
Engine load
+
+
+
diff --git a/examples/components/geometry.php b/examples/components/geometry.php
new file mode 100644
index 0000000..be8457a
--- /dev/null
+++ b/examples/components/geometry.php
@@ -0,0 +1,8 @@
+
+ Element size and text truncation
+ The resizable box has data-flux="flux-size", which exposes its width and height, including padding and borders, as CSS variables. CSS counters display those dimensions as you resize it.
+ The paragraph uses flux-truncated to expose whether its text is clipped. The connection (flux-truncated@#truncation-note) copies that value to the note below, where CSS uses it as the opacity. This makes the explanation visible only when the text does not fit.
+
This text is clipped when its width exceeds the available space inside the box.
+ The description is clipped. Widen the box to reveal it.
+ Viewport visibility The border uses --flux-visible. The entry transition on every demo uses --flux-first-visible, which remembers its first appearance.
+
diff --git a/examples/components/image-palette.php b/examples/components/image-palette.php
new file mode 100644
index 0000000..b942835
--- /dev/null
+++ b/examples/components/image-palette.php
@@ -0,0 +1,14 @@
+
+ Image palette
+
The figure has data-flux="flux-palette". Flux samples its image and exposes the dominant, accent, dark, light and average colours as CSS variables.
+
The shadow uses --flux-palette, while the swatches use properties such as --flux-palette-accent. CSS positions the temperature marker using --flux-palette-temp. These styles derive their colours from the image, so changing the image does not require choosing a new set of CSS colours.
+
Choose an image below to replace the preview. A small script creates a local object URL for the selected file; the file is not uploaded. Flux samples the replacement image when it loads.
+
+
+ Choose your own image
+
+ Colours sampled from the image
+
+
+
+
diff --git a/examples/components/levels.php b/examples/components/levels.php
new file mode 100644
index 0000000..c6ade55
--- /dev/null
+++ b/examples/components/levels.php
@@ -0,0 +1,14 @@
+
+
+
Vertical range input
+
An HTML range input has five discrete values, labelled from OFF to EXTREME. CSS makes the input vertical, and data-flux="flux-range" on its container exposes its value as --flux-range, normalised from zero to one.
+
CSS multiplies the variable by four to recover the selected step. It uses that step to colour the labels and set their opacity, showing the current label above the control. The input retains its native pointer and keyboard controls.
+
+
+
$label): ?>= $label ?>
+
+
Power level: 0 off, 1 low, 2 mid, 3 high, 4 extreme
+
$label): ?>= $label ?>
+
+
+
diff --git a/examples/components/list.php b/examples/components/list.php
new file mode 100644
index 0000000..52eb12c
--- /dev/null
+++ b/examples/components/list.php
@@ -0,0 +1,26 @@
+
+
+
The list is empty. Use the form below to add an item.
+
+
+ = $listName === 'todo' ? 'New task' : 'Shopping item' ?>
+ Add = $listName === 'todo' ? 'task' : 'item' ?>
+
+
diff --git a/examples/components/navigation.php b/examples/components/navigation.php
new file mode 100644
index 0000000..dae0d82
--- /dev/null
+++ b/examples/components/navigation.php
@@ -0,0 +1,16 @@
+
+ Link navigation
+ The chapter links below have the data-flux="link" attributes, which Flux uses to perform the requests in the background when clicked.
+ When the links are clicked, the elements marked with the data-flux="update-link" attribute are updated with the new content from the server.
+ The scratchpad textarea sits outside the update targets, so its text remains in place after other elements' contents have been replaced.
+ Following the flux-links also changes the browser's URL, and the browser back and forward actions navigate through the state as if performing the actions for real.
+
+ Your scratchpad
+
+
+
= input($_GET, 'chapter') === 'two' ? 'Chapter two: CSS properties' : 'Chapter one: HTML requests' ?>
+
= input($_GET, 'chapter') === 'two' ? 'CSS can use live values to draw, position and animate elements.' : 'A form submits to the server. A link requests the next document. Flux can make these requests in the background.' ?>
+
+ This inner link target is also on chapter = input($_GET, 'chapter') === 'two' ? 'two' : 'one' ?> .
+ data-flux-scroll="smooth" on the page restores scroll smoothly during navigation. Use auto for an immediate restoration.
+
diff --git a/examples/components/pointer.php b/examples/components/pointer.php
new file mode 100644
index 0000000..f1557a9
--- /dev/null
+++ b/examples/components/pointer.php
@@ -0,0 +1,8 @@
+
+ Local and viewport pointer coordinates
+ The pad has data-flux="flux-pointer", which exposes pointer coordinates relative to the pad as CSS variables. CSS multiplies the normalised X and Y values, from zero to one, by 100% to position the marker. CSS counters in ::after display pixel coordinates clamped to the pad's edges.
+ The connection (flux-pointer,flux-size@#pointer-preview) copies the pad's pointer and size variables to the smaller preview. Its marker uses the normalised coordinates to fit its own dimensions, while its readout shows the source pixels. The viewport readout uses global pointer coordinates measured from the browser viewport's top-left corner.
+ Move your pointer here.
Local:
+ Connected preview
Source pixels:
+ Viewport:
+
diff --git a/examples/components/polling.php b/examples/components/polling.php
new file mode 100644
index 0000000..a91eedc
--- /dev/null
+++ b/examples/components/polling.php
@@ -0,0 +1,6 @@
+
+ Polling for server updates
+ PHP renders each time when the page is requested. The elements use data-flux="live" or data-flux="live-inner" with data-flux-rate="5" to request updates every five seconds while visible. Polling pauses when these elements leave the viewport or the tab is hidden.
+ Flux finds the matching IDs in the returned HTML. live replaces the whole element, including its attributes; live-inner replaces only its contents. live-outer is an explicit alias for live. This reuses the page's PHP rendering to show changing server data without a separate polling endpoint.
+ Outer: = date('H:i:s') ?>
Inner: = date('H:i:s') ?>
Explicit outer alias: = date('H:i:s') ?>
+
diff --git a/examples/components/preferences.php b/examples/components/preferences.php
new file mode 100644
index 0000000..32a0d45
--- /dev/null
+++ b/examples/components/preferences.php
@@ -0,0 +1,16 @@
+
+ Input values and validation state
+ The control containers use flux-select and flux-color to expose their values as CSS variables. CSS uses --flux-select for the preview's column count, and a connection copies the colour to the separate preview. CSS mixes that colour with white for the background and inverts the background's RGB channels for the text.
+ The name label uses flux-field to expose its remaining character count and editing state. The form uses flux-form to expose how many fields pass native validation. CSS uses those values for the labels and progress bar, so the feedback follows the HTML input constraints.
+ The form also has auto to submit in the background. PHP saves the display name in the session, and the separate update target shows the saved value. The column and colour controls provide local previews.
+
+
+ Preview columns One Two Three One Two Three
+ Preview colour
+ Display name (3–24 letters or spaces) Edited Visited Different from initial value
+
+ Save preferences Reset
+
+ Connected colour preview This preview receives the colour through (flux-color@#colour-preview). The border uses the selected colour, the background uses a tint, and the text uses the inverse of that tint. The connection follows the form when it is replaced.
+ Saved name: = h($_SESSION['display-name'] ?? 'Not saved yet') ?>
+
diff --git a/examples/components/scroll-offset.php b/examples/components/scroll-offset.php
new file mode 100644
index 0000000..6624a34
--- /dev/null
+++ b/examples/components/scroll-offset.php
@@ -0,0 +1,20 @@
+
diff --git a/examples/components/scroll-passage.php b/examples/components/scroll-passage.php
new file mode 100644
index 0000000..92652b9
--- /dev/null
+++ b/examples/components/scroll-passage.php
@@ -0,0 +1,26 @@
+
diff --git a/examples/components/search.php b/examples/components/search.php
new file mode 100644
index 0000000..cab337c
--- /dev/null
+++ b/examples/components/search.php
@@ -0,0 +1,17 @@
+
+
+
Search with autocomplete
+
The search is a GET form. PHP reads its query, searches a local list of 600 cities by name or country, and renders the results as HTML links.
+
Adding data-flux="autocomplete" to the form makes Flux request that page as you type and display the part marked data-flux="autocomplete-results". The suggestions reuse the server's search and HTML rendering. data-flux-min-length="0" sends empty queries too, so the server can replace the suggestions with a prompt when the field is cleared.
+
The arrow keys move focus through the suggestions. Enter in the search field submits a GET request in the background; selecting a city link requests a server-rendered dialog. The wrapper's data-flux-history="false" keeps searches and city selections out of the page URL, so refreshing does not reopen the dialog.
+
+
+
+
+
+ Search cities
+ Search
+
+
+
+
diff --git a/examples/components/todo.php b/examples/components/todo.php
new file mode 100644
index 0000000..7110110
--- /dev/null
+++ b/examples/components/todo.php
@@ -0,0 +1,9 @@
+
+
+
To-do list
+
Each <li> contains forms for changing its position, completion and removal. Adding data-flux="drag-order" to the item lets Flux fill in and submit its order input when it is dropped. The list's data-flux-drag-handle="⠿" attribute supplies the handle text.
+
PHP saves the items, their completion and their order in the session. The list container uses data-flux="update-inner" to replace its contents from the returned page after a submission. Without JavaScript, the position input and Move button remain available.
+
Add a task, mark it as complete, or drag its handle to change the order.
+
+
+
diff --git a/examples/components/updates.php b/examples/components/updates.php
new file mode 100644
index 0000000..95588fe
--- /dev/null
+++ b/examples/components/updates.php
@@ -0,0 +1,12 @@
+
+ Update targets
+ The button below is contained within an HTML form and has the data-flux="submit" attribute, which causes the POST to be submitted in the background. The POST causes PHP to toggle a session value and then re-renders the page on the server.
+ Each output target has an ID and a directive that determines what Flux copies from the response: update-outer replaces the element, update-inner replaces its contents, and update-attributes copies only its attributes. The last output target only receives attribute updates, and with that, changes emphasis through its class.
+ Toggle emphasis
+
+
Outer update: = ($_SESSION['emphasis'] ?? false) ? 'on' : 'off' ?>
+
Inner update: = ($_SESSION['emphasis'] ?? false) ? 'on' : 'off' ?>
+
This element receives attribute updates only.
+
+ update is an alias for update-outer. Empty data-flux on a form, link or button is the shorthand for its automatic behaviour.
+
diff --git a/examples/components/video-palette.php b/examples/components/video-palette.php
new file mode 100644
index 0000000..8fb20c4
--- /dev/null
+++ b/examples/components/video-palette.php
@@ -0,0 +1,11 @@
+
+ Video palette
+
The figure uses the same data-flux="flux-palette" attribute as the image example, but contains a <video>. Flux samples the current frame and updates the palette variables during playback, pausing sampling when the figure is off-screen.
+
The shadow, swatches and temperature marker use those variables in CSS. The same styles work for both images and video; Flux updates the palette during playback. A small script checks whether the remote Big Buck Bunny sample is reachable and switches to it when available. The bundled clip is used if the request fails or the remote video cannot load. Use the native video controls to play or seek.
+
+
+ Play the video to update its palette. Remote sample: Big Buck Bunny , © Blender Foundation, CC BY 3.0 .
+
+
+
+
diff --git a/examples/data/README.md b/examples/data/README.md
new file mode 100644
index 0000000..7ac305a
--- /dev/null
+++ b/examples/data/README.md
@@ -0,0 +1,9 @@
+# City search data
+
+`cities.json` contains 600 populated places selected from [GeoNames cities15000](https://download.geonames.org/export/dump/), downloaded on 7 September 2026. Country and territory labels come from GeoNames `countryInfo.txt`.
+
+The selection includes the two most populous eligible places in each country or territory represented in the source, then fills the remaining places by population. Only populated-place and administrative-seat feature codes are included. It stores the GeoNames ID, name, ASCII name for searches without accents, country label and time zone. Population was used for selection and is not included in this fixture.
+
+GeoNames data is provided under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). This is a filtered snapshot for demonstrating search, not a complete or current city register. The source data has been reduced to the fields and records described above. Search results include attribution.
+
+The website reads this file locally. It does not call GeoNames or any other external service when searching or selecting a city.
diff --git a/examples/data/cities.json b/examples/data/cities.json
new file mode 100644
index 0000000..3001716
--- /dev/null
+++ b/examples/data/cities.json
@@ -0,0 +1,4202 @@
+[
+ {
+ "id": "2293538",
+ "name": "Abidjan",
+ "ascii": "Abidjan",
+ "country": "Ivory Coast",
+ "timezone": "Africa/Abidjan"
+ },
+ {
+ "id": "2293521",
+ "name": "Abobo",
+ "ascii": "Abobo",
+ "country": "Ivory Coast",
+ "timezone": "Africa/Abidjan"
+ },
+ {
+ "id": "2395914",
+ "name": "Abomey-Calavi",
+ "ascii": "Abomey-Calavi",
+ "country": "Benin",
+ "timezone": "Africa/Porto-Novo"
+ },
+ {
+ "id": "292968",
+ "name": "Abu Dhabi",
+ "ascii": "Abu Dhabi",
+ "country": "United Arab Emirates",
+ "timezone": "Asia/Dubai"
+ },
+ {
+ "id": "2352778",
+ "name": "Abuja",
+ "ascii": "Abuja",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "2306104",
+ "name": "Accra",
+ "ascii": "Accra",
+ "country": "Ghana",
+ "timezone": "Africa/Accra"
+ },
+ {
+ "id": "4030723",
+ "name": "Adamstown",
+ "ascii": "Adamstown",
+ "country": "Pitcairn",
+ "timezone": "Pacific/Pitcairn"
+ },
+ {
+ "id": "325363",
+ "name": "Adana",
+ "ascii": "Adana",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "344979",
+ "name": "Addis Ababa",
+ "ascii": "Addis Ababa",
+ "country": "Ethiopia",
+ "timezone": "Africa/Addis_Ababa"
+ },
+ {
+ "id": "415189",
+ "name": "Aden",
+ "ascii": "Aden",
+ "country": "Yemen",
+ "timezone": "Asia/Aden"
+ },
+ {
+ "id": "1279233",
+ "name": "Ahmedabad",
+ "ascii": "Ahmedabad",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "285839",
+ "name": "Al Aḩmadī",
+ "ascii": "Al Ahmadi",
+ "country": "Kuwait",
+ "timezone": "Asia/Kuwait"
+ },
+ {
+ "id": "290332",
+ "name": "Al Muharraq",
+ "ascii": "Al Muharraq",
+ "country": "Bahrain",
+ "timezone": "Asia/Bahrain"
+ },
+ {
+ "id": "170063",
+ "name": "Aleppo",
+ "ascii": "Aleppo",
+ "country": "Syria",
+ "timezone": "Asia/Damascus"
+ },
+ {
+ "id": "361058",
+ "name": "Alexandria",
+ "ascii": "Alexandria",
+ "country": "Egypt",
+ "timezone": "Africa/Cairo"
+ },
+ {
+ "id": "2507480",
+ "name": "Algiers",
+ "ascii": "Algiers",
+ "country": "Algeria",
+ "timezone": "Africa/Algiers"
+ },
+ {
+ "id": "1526384",
+ "name": "Almaty",
+ "ascii": "Almaty",
+ "country": "Kazakhstan",
+ "timezone": "Asia/Almaty"
+ },
+ {
+ "id": "4036284",
+ "name": "Alofi",
+ "ascii": "Alofi",
+ "country": "Niue",
+ "timezone": "Pacific/Niue"
+ },
+ {
+ "id": "250441",
+ "name": "Amman",
+ "ascii": "Amman",
+ "country": "Jordan",
+ "timezone": "Asia/Amman"
+ },
+ {
+ "id": "2759794",
+ "name": "Amsterdam",
+ "ascii": "Amsterdam",
+ "country": "The Netherlands",
+ "timezone": "Europe/Amsterdam"
+ },
+ {
+ "id": "1514588",
+ "name": "Andijon",
+ "ascii": "Andijon",
+ "country": "Uzbekistan",
+ "timezone": "Asia/Tashkent"
+ },
+ {
+ "id": "3041563",
+ "name": "Andorra la Vella",
+ "ascii": "Andorra la Vella",
+ "country": "Andorra",
+ "timezone": "Europe/Andorra"
+ },
+ {
+ "id": "323786",
+ "name": "Ankara",
+ "ascii": "Ankara",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "2038632",
+ "name": "Anshan",
+ "ascii": "Anshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1070940",
+ "name": "Antananarivo",
+ "ascii": "Antananarivo",
+ "country": "Madagascar",
+ "timezone": "Indian/Antananarivo"
+ },
+ {
+ "id": "2803138",
+ "name": "Antwerp",
+ "ascii": "Antwerp",
+ "country": "Belgium",
+ "timezone": "Europe/Brussels"
+ },
+ {
+ "id": "4035413",
+ "name": "Apia",
+ "ascii": "Apia",
+ "country": "Samoa",
+ "timezone": "Pacific/Apia"
+ },
+ {
+ "id": "289888",
+ "name": "Ar Rayyān",
+ "ascii": "Ar Rayyan",
+ "country": "Qatar",
+ "timezone": "Asia/Qatar"
+ },
+ {
+ "id": "2624652",
+ "name": "Århus",
+ "ascii": "Arhus",
+ "country": "Denmark",
+ "timezone": "Europe/Copenhagen"
+ },
+ {
+ "id": "162183",
+ "name": "Ashgabat",
+ "ascii": "Ashgabat",
+ "country": "Turkmenistan",
+ "timezone": "Asia/Ashgabat"
+ },
+ {
+ "id": "343300",
+ "name": "Asmara",
+ "ascii": "Asmara",
+ "country": "Eritrea",
+ "timezone": "Africa/Asmara"
+ },
+ {
+ "id": "1526273",
+ "name": "Astana",
+ "ascii": "Astana",
+ "country": "Kazakhstan",
+ "timezone": "Asia/Almaty"
+ },
+ {
+ "id": "3439389",
+ "name": "Asunción",
+ "ascii": "Asuncion",
+ "country": "Paraguay",
+ "timezone": "America/Asuncion"
+ },
+ {
+ "id": "264371",
+ "name": "Athens",
+ "ascii": "Athens",
+ "country": "Greece",
+ "timezone": "Europe/Athens"
+ },
+ {
+ "id": "2193733",
+ "name": "Auckland",
+ "ascii": "Auckland",
+ "country": "New Zealand",
+ "timezone": "Pacific/Auckland"
+ },
+ {
+ "id": "4035715",
+ "name": "Avarua",
+ "ascii": "Avarua",
+ "country": "Cook Islands",
+ "timezone": "Pacific/Rarotonga"
+ },
+ {
+ "id": "98182",
+ "name": "Baghdad",
+ "ascii": "Baghdad",
+ "country": "Iraq",
+ "timezone": "Asia/Baghdad"
+ },
+ {
+ "id": "3579767",
+ "name": "Baie-Mahault",
+ "ascii": "Baie-Mahault",
+ "country": "Guadeloupe",
+ "timezone": "America/Guadeloupe"
+ },
+ {
+ "id": "587084",
+ "name": "Baku",
+ "ascii": "Baku",
+ "country": "Azerbaijan",
+ "timezone": "Asia/Baku"
+ },
+ {
+ "id": "7648070",
+ "name": "Balbala",
+ "ascii": "Balbala",
+ "country": "Djibouti",
+ "timezone": "Africa/Djibouti"
+ },
+ {
+ "id": "2460596",
+ "name": "Bamako",
+ "ascii": "Bamako",
+ "country": "Mali",
+ "timezone": "Africa/Bamako"
+ },
+ {
+ "id": "1820906",
+ "name": "Bandar Seri Begawan",
+ "ascii": "Bandar Seri Begawan",
+ "country": "Brunei",
+ "timezone": "Asia/Brunei"
+ },
+ {
+ "id": "13308487",
+ "name": "Bandariba",
+ "ascii": "Bandariba",
+ "country": "Curacao",
+ "timezone": "America/Curacao"
+ },
+ {
+ "id": "1650357",
+ "name": "Bandung",
+ "ascii": "Bandung",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "1609350",
+ "name": "Bangkok",
+ "ascii": "Bangkok",
+ "country": "Thailand",
+ "timezone": "Asia/Bangkok"
+ },
+ {
+ "id": "2389853",
+ "name": "Bangui",
+ "ascii": "Bangui",
+ "country": "Central African Republic",
+ "timezone": "Africa/Bangui"
+ },
+ {
+ "id": "3204541",
+ "name": "Banja Luka",
+ "ascii": "Banja Luka",
+ "country": "Bosnia and Herzegovina",
+ "timezone": "Europe/Sarajevo"
+ },
+ {
+ "id": "13308620",
+ "name": "Bao'an",
+ "ascii": "Bao'an",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1816971",
+ "name": "Baoding",
+ "ascii": "Baoding",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1816917",
+ "name": "Baoshan",
+ "ascii": "Baoshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2038432",
+ "name": "Baotou",
+ "ascii": "Baotou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3128760",
+ "name": "Barcelona",
+ "ascii": "Barcelona",
+ "country": "Spain",
+ "timezone": "Europe/Madrid"
+ },
+ {
+ "id": "3575551",
+ "name": "Basseterre",
+ "ascii": "Basseterre",
+ "country": "Saint Kitts and Nevis",
+ "timezone": "America/St_Kitts"
+ },
+ {
+ "id": "2310046",
+ "name": "Bata",
+ "ascii": "Bata",
+ "country": "Equatorial Guinea",
+ "timezone": "Africa/Malabo"
+ },
+ {
+ "id": "615532",
+ "name": "Batumi",
+ "ascii": "Batumi",
+ "country": "Georgia",
+ "timezone": "Asia/Tbilisi"
+ },
+ {
+ "id": "4562831",
+ "name": "Bayamón",
+ "ascii": "Bayamon",
+ "country": "Puerto Rico",
+ "timezone": "America/Puerto_Rico"
+ },
+ {
+ "id": "11838258",
+ "name": "Bayan Nur",
+ "ascii": "Bayan Nur",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "11995100",
+ "name": "Bazhong",
+ "ascii": "Bazhong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1816670",
+ "name": "Beijing",
+ "ascii": "Beijing",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "276781",
+ "name": "Beirut",
+ "ascii": "Beirut",
+ "country": "Lebanon",
+ "timezone": "Asia/Beirut"
+ },
+ {
+ "id": "1649378",
+ "name": "Bekasi",
+ "ascii": "Bekasi",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "792680",
+ "name": "Belgrade",
+ "ascii": "Belgrade",
+ "country": "Serbia",
+ "timezone": "Europe/Belgrade"
+ },
+ {
+ "id": "3582677",
+ "name": "Belize City",
+ "ascii": "Belize City",
+ "country": "Belize",
+ "timezone": "America/Belize"
+ },
+ {
+ "id": "3470127",
+ "name": "Belo Horizonte",
+ "ascii": "Belo Horizonte",
+ "country": "Brazil",
+ "timezone": "America/Sao_Paulo"
+ },
+ {
+ "id": "1277333",
+ "name": "Bengaluru",
+ "ascii": "Bengaluru",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "88319",
+ "name": "Benghazi",
+ "ascii": "Benghazi",
+ "country": "Libya",
+ "timezone": "Africa/Tripoli"
+ },
+ {
+ "id": "2347283",
+ "name": "Benin City",
+ "ascii": "Benin City",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "3161732",
+ "name": "Bergen",
+ "ascii": "Bergen",
+ "country": "Norway",
+ "timezone": "Europe/Oslo"
+ },
+ {
+ "id": "2950159",
+ "name": "Berlin",
+ "ascii": "Berlin",
+ "country": "Germany",
+ "timezone": "Europe/Berlin"
+ },
+ {
+ "id": "1275841",
+ "name": "Bhopal",
+ "ascii": "Bhopal",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "2388873",
+ "name": "Bimbo",
+ "ascii": "Bimbo",
+ "country": "Central African Republic",
+ "timezone": "Africa/Bangui"
+ },
+ {
+ "id": "2563191",
+ "name": "Birkirkara",
+ "ascii": "Birkirkara",
+ "country": "Malta",
+ "timezone": "Europe/Malta"
+ },
+ {
+ "id": "2655603",
+ "name": "Birmingham",
+ "ascii": "Birmingham",
+ "country": "United Kingdom",
+ "timezone": "Europe/London"
+ },
+ {
+ "id": "1528675",
+ "name": "Bishkek",
+ "ascii": "Bishkek",
+ "country": "Kyrgyzstan",
+ "timezone": "Asia/Bishkek"
+ },
+ {
+ "id": "2374775",
+ "name": "Bissau",
+ "ascii": "Bissau",
+ "country": "Guinea-Bissau",
+ "timezone": "Africa/Bissau"
+ },
+ {
+ "id": "931755",
+ "name": "Blantyre",
+ "ascii": "Blantyre",
+ "country": "Malawi",
+ "timezone": "Africa/Blantyre"
+ },
+ {
+ "id": "2362344",
+ "name": "Bobo-Dioulasso",
+ "ascii": "Bobo-Dioulasso",
+ "country": "Burkina Faso",
+ "timezone": "Africa/Ouagadougou"
+ },
+ {
+ "id": "3688689",
+ "name": "Bogotá",
+ "ascii": "Bogota",
+ "country": "Colombia",
+ "timezone": "America/Bogota"
+ },
+ {
+ "id": "64021",
+ "name": "Borama",
+ "ascii": "Borama",
+ "country": "Somalia",
+ "timezone": "Africa/Mogadishu"
+ },
+ {
+ "id": "3469058",
+ "name": "Brasília",
+ "ascii": "Brasilia",
+ "country": "Brazil",
+ "timezone": "America/Sao_Paulo"
+ },
+ {
+ "id": "3060972",
+ "name": "Bratislava",
+ "ascii": "Bratislava",
+ "country": "Slovakia",
+ "timezone": "Europe/Bratislava"
+ },
+ {
+ "id": "2260535",
+ "name": "Brazzaville",
+ "ascii": "Brazzaville",
+ "country": "Republic of the Congo",
+ "timezone": "Africa/Brazzaville"
+ },
+ {
+ "id": "3374036",
+ "name": "Bridgetown",
+ "ascii": "Bridgetown",
+ "country": "Barbados",
+ "timezone": "America/Barbados"
+ },
+ {
+ "id": "2413753",
+ "name": "Brikama",
+ "ascii": "Brikama",
+ "country": "Gambia",
+ "timezone": "Africa/Banjul"
+ },
+ {
+ "id": "2174003",
+ "name": "Brisbane",
+ "ascii": "Brisbane",
+ "country": "Australia",
+ "timezone": "Australia/Brisbane"
+ },
+ {
+ "id": "3078610",
+ "name": "Brno",
+ "ascii": "Brno",
+ "country": "Czechia",
+ "timezone": "Europe/Prague"
+ },
+ {
+ "id": "5110302",
+ "name": "Brooklyn",
+ "ascii": "Brooklyn",
+ "country": "United States",
+ "timezone": "America/New_York"
+ },
+ {
+ "id": "2800866",
+ "name": "Brussels",
+ "ascii": "Brussels",
+ "country": "Belgium",
+ "timezone": "Europe/Brussels"
+ },
+ {
+ "id": "683506",
+ "name": "Bucharest",
+ "ascii": "Bucharest",
+ "country": "Romania",
+ "timezone": "Europe/Bucharest"
+ },
+ {
+ "id": "3054643",
+ "name": "Budapest",
+ "ascii": "Budapest",
+ "country": "Hungary",
+ "timezone": "Europe/Budapest"
+ },
+ {
+ "id": "3435910",
+ "name": "Buenos Aires",
+ "ascii": "Buenos Aires",
+ "country": "Argentina",
+ "timezone": "America/Argentina/Buenos_Aires"
+ },
+ {
+ "id": "425378",
+ "name": "Bujumbura",
+ "ascii": "Bujumbura",
+ "country": "Burundi",
+ "timezone": "Africa/Bujumbura"
+ },
+ {
+ "id": "894701",
+ "name": "Bulawayo",
+ "ascii": "Bulawayo",
+ "country": "Zimbabwe",
+ "timezone": "Africa/Harare"
+ },
+ {
+ "id": "750269",
+ "name": "Bursa",
+ "ascii": "Bursa",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "1838524",
+ "name": "Busan",
+ "ascii": "Busan",
+ "country": "South Korea",
+ "timezone": "Asia/Seoul"
+ },
+ {
+ "id": "360630",
+ "name": "Cairo",
+ "ascii": "Cairo",
+ "country": "Egypt",
+ "timezone": "Africa/Cairo"
+ },
+ {
+ "id": "3687925",
+ "name": "Cali",
+ "ascii": "Cali",
+ "country": "Colombia",
+ "timezone": "America/Bogota"
+ },
+ {
+ "id": "3946083",
+ "name": "Callao",
+ "ascii": "Callao",
+ "country": "Peru",
+ "timezone": "America/Lima"
+ },
+ {
+ "id": "3748746",
+ "name": "Calliaqua",
+ "ascii": "Calliaqua",
+ "country": "Saint Vincent and the Grenadines",
+ "timezone": "America/St_Vincent"
+ },
+ {
+ "id": "1720151",
+ "name": "Caloocan",
+ "ascii": "Caloocan",
+ "country": "Philippines",
+ "timezone": "Asia/Manila"
+ },
+ {
+ "id": "2422488",
+ "name": "Camayenne",
+ "ascii": "Camayenne",
+ "country": "Guinea",
+ "timezone": "Africa/Conakry"
+ },
+ {
+ "id": "3369157",
+ "name": "Cape Town",
+ "ascii": "Cape Town",
+ "country": "South Africa",
+ "timezone": "Africa/Johannesburg"
+ },
+ {
+ "id": "3646738",
+ "name": "Caracas",
+ "ascii": "Caracas",
+ "country": "Venezuela",
+ "timezone": "America/Caracas"
+ },
+ {
+ "id": "6201369",
+ "name": "Carrefour",
+ "ascii": "Carrefour",
+ "country": "Haiti",
+ "timezone": "America/Port-au-Prince"
+ },
+ {
+ "id": "2553604",
+ "name": "Casablanca",
+ "ascii": "Casablanca",
+ "country": "Morocco",
+ "timezone": "Africa/Casablanca"
+ },
+ {
+ "id": "3576812",
+ "name": "Castries",
+ "ascii": "Castries",
+ "country": "Saint Lucia",
+ "timezone": "America/St_Lucia"
+ },
+ {
+ "id": "3382160",
+ "name": "Cayenne",
+ "ascii": "Cayenne",
+ "country": "French Guiana",
+ "timezone": "America/Cayenne"
+ },
+ {
+ "id": "3574810",
+ "name": "Chaguanas",
+ "ascii": "Chaguanas",
+ "country": "Trinidad and Tobago",
+ "timezone": "America/Port_of_Spain"
+ },
+ {
+ "id": "2038180",
+ "name": "Changchun",
+ "ascii": "Changchun",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1815577",
+ "name": "Changsha",
+ "ascii": "Changsha",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1815456",
+ "name": "Changzhou",
+ "ascii": "Changzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1815395",
+ "name": "Chaozhou",
+ "ascii": "Chaozhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "4795467",
+ "name": "Charlotte Amalie",
+ "ascii": "Charlotte Amalie",
+ "country": "U.S. Virgin Islands",
+ "timezone": "America/St_Thomas"
+ },
+ {
+ "id": "1205733",
+ "name": "Chattogram",
+ "ascii": "Chattogram",
+ "country": "Bangladesh",
+ "timezone": "Asia/Dhaka"
+ },
+ {
+ "id": "1815286",
+ "name": "Chengdu",
+ "ascii": "Chengdu",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1793036",
+ "name": "Chengtangcun",
+ "ascii": "Chengtangcun",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1264527",
+ "name": "Chennai",
+ "ascii": "Chennai",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "4887398",
+ "name": "Chicago",
+ "ascii": "Chicago",
+ "country": "United States",
+ "timezone": "America/Chicago"
+ },
+ {
+ "id": "618426",
+ "name": "Chisinau",
+ "ascii": "Chisinau",
+ "country": "Moldova",
+ "timezone": "Europe/Chisinau"
+ },
+ {
+ "id": "1814906",
+ "name": "Chongqing",
+ "ascii": "Chongqing",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2192362",
+ "name": "Christchurch",
+ "ascii": "Christchurch",
+ "country": "New Zealand",
+ "timezone": "Pacific/Auckland"
+ },
+ {
+ "id": "3439101",
+ "name": "Ciudad del Este",
+ "ascii": "Ciudad del Este",
+ "country": "Paraguay",
+ "timezone": "America/Asuncion"
+ },
+ {
+ "id": "3919968",
+ "name": "Cochabamba",
+ "ascii": "Cochabamba",
+ "country": "Bolivia",
+ "timezone": "America/La_Paz"
+ },
+ {
+ "id": "3576994",
+ "name": "Cockburn Town",
+ "ascii": "Cockburn Town",
+ "country": "Turks and Caicos Islands",
+ "timezone": "America/Grand_Turk"
+ },
+ {
+ "id": "1273865",
+ "name": "Coimbatore",
+ "ascii": "Coimbatore",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1248991",
+ "name": "Colombo",
+ "ascii": "Colombo",
+ "country": "Sri Lanka",
+ "timezone": "Asia/Colombo"
+ },
+ {
+ "id": "2422465",
+ "name": "Conakry",
+ "ascii": "Conakry",
+ "country": "Guinea",
+ "timezone": "Africa/Conakry"
+ },
+ {
+ "id": "2618425",
+ "name": "Copenhagen",
+ "ascii": "Copenhagen",
+ "country": "Denmark",
+ "timezone": "Europe/Copenhagen"
+ },
+ {
+ "id": "3860259",
+ "name": "Córdoba",
+ "ascii": "Cordoba",
+ "country": "Argentina",
+ "timezone": "America/Argentina/Cordoba"
+ },
+ {
+ "id": "2965140",
+ "name": "Cork",
+ "ascii": "Cork",
+ "country": "Ireland",
+ "timezone": "Europe/Dublin"
+ },
+ {
+ "id": "3464975",
+ "name": "Curitiba",
+ "ascii": "Curitiba",
+ "country": "Brazil",
+ "timezone": "America/Sao_Paulo"
+ },
+ {
+ "id": "1835329",
+ "name": "Daegu",
+ "ascii": "Daegu",
+ "country": "South Korea",
+ "timezone": "Asia/Seoul"
+ },
+ {
+ "id": "2253354",
+ "name": "Dakar",
+ "ascii": "Dakar",
+ "country": "Senegal",
+ "timezone": "Africa/Dakar"
+ },
+ {
+ "id": "2463447",
+ "name": "Dakhla",
+ "ascii": "Dakhla",
+ "country": "Western Sahara",
+ "timezone": "Africa/El_Aaiun"
+ },
+ {
+ "id": "8347657",
+ "name": "Dalap-Uliga-Dorrit",
+ "ascii": "Dalap-Uliga-Dorrit",
+ "country": "Marshall Islands",
+ "timezone": "Pacific/Majuro"
+ },
+ {
+ "id": "1814087",
+ "name": "Dalian",
+ "ascii": "Dalian",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "170654",
+ "name": "Damascus",
+ "ascii": "Damascus",
+ "country": "Syria",
+ "timezone": "Asia/Damascus"
+ },
+ {
+ "id": "160263",
+ "name": "Dar es Salaam",
+ "ascii": "Dar es Salaam",
+ "country": "Tanzania",
+ "timezone": "Africa/Dar_es_Salaam"
+ },
+ {
+ "id": "2037799",
+ "name": "Datong",
+ "ascii": "Datong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "460413",
+ "name": "Daugavpils",
+ "ascii": "Daugavpils",
+ "country": "Latvia",
+ "timezone": "Europe/Riga"
+ },
+ {
+ "id": "1715348",
+ "name": "Davao",
+ "ascii": "Davao",
+ "country": "Philippines",
+ "timezone": "Asia/Manila"
+ },
+ {
+ "id": "721472",
+ "name": "Debrecen",
+ "ascii": "Debrecen",
+ "country": "Hungary",
+ "timezone": "Europe/Budapest"
+ },
+ {
+ "id": "4043909",
+ "name": "Dededo Village",
+ "ascii": "Dededo Village",
+ "country": "Guam",
+ "timezone": "Pacific/Guam"
+ },
+ {
+ "id": "1234569",
+ "name": "Dehiwala-Mount Lavinia",
+ "ascii": "Dehiwala-Mount Lavinia",
+ "country": "Sri Lanka",
+ "timezone": "Asia/Colombo"
+ },
+ {
+ "id": "1273294",
+ "name": "Delhi",
+ "ascii": "Delhi",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1645524",
+ "name": "Depok",
+ "ascii": "Depok",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "1185241",
+ "name": "Dhaka",
+ "ascii": "Dhaka",
+ "country": "Bangladesh",
+ "timezone": "Asia/Dhaka"
+ },
+ {
+ "id": "1645457",
+ "name": "Dili",
+ "ascii": "Dili",
+ "country": "Timor Leste",
+ "timezone": "Asia/Dili"
+ },
+ {
+ "id": "316541",
+ "name": "Diyarbakır",
+ "ascii": "Diyarbakir",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "223817",
+ "name": "Djibouti",
+ "ascii": "Djibouti",
+ "country": "Djibouti",
+ "timezone": "Africa/Djibouti"
+ },
+ {
+ "id": "290030",
+ "name": "Doha",
+ "ascii": "Doha",
+ "country": "Qatar",
+ "timezone": "Asia/Qatar"
+ },
+ {
+ "id": "1812545",
+ "name": "Dongguan",
+ "ascii": "Dongguan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2232593",
+ "name": "Douala",
+ "ascii": "Douala",
+ "country": "Cameroon",
+ "timezone": "Africa/Douala"
+ },
+ {
+ "id": "3042237",
+ "name": "Douglas",
+ "ascii": "Douglas",
+ "country": "Isle of Man",
+ "timezone": "Europe/Isle_of_Man"
+ },
+ {
+ "id": "292223",
+ "name": "Dubai",
+ "ascii": "Dubai",
+ "country": "United Arab Emirates",
+ "timezone": "Asia/Dubai"
+ },
+ {
+ "id": "2964574",
+ "name": "Dublin",
+ "ascii": "Dublin",
+ "country": "Ireland",
+ "timezone": "Europe/Dublin"
+ },
+ {
+ "id": "1007311",
+ "name": "Durban",
+ "ascii": "Durban",
+ "country": "South Africa",
+ "timezone": "Africa/Johannesburg"
+ },
+ {
+ "id": "3185728",
+ "name": "Durrës",
+ "ascii": "Durres",
+ "country": "Albania",
+ "timezone": "Europe/Tirane"
+ },
+ {
+ "id": "1221874",
+ "name": "Dushanbe",
+ "ascii": "Dushanbe",
+ "country": "Tajikistan",
+ "timezone": "Asia/Dushanbe"
+ },
+ {
+ "id": "2031405",
+ "name": "Erdenet",
+ "ascii": "Erdenet",
+ "country": "Mongolia",
+ "timezone": "Asia/Ulaanbaatar"
+ },
+ {
+ "id": "2960596",
+ "name": "Esch-sur-Alzette",
+ "ascii": "Esch-sur-Alzette",
+ "country": "Luxembourg",
+ "timezone": "Europe/Luxembourg"
+ },
+ {
+ "id": "660158",
+ "name": "Espoo",
+ "ascii": "Espoo",
+ "country": "Finland",
+ "timezone": "Europe/Helsinki"
+ },
+ {
+ "id": "4034561",
+ "name": "Faaa",
+ "ascii": "Faaa",
+ "country": "French Polynesia",
+ "timezone": "Pacific/Tahiti"
+ },
+ {
+ "id": "1179400",
+ "name": "Faisalabad",
+ "ascii": "Faisalabad",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "2078127",
+ "name": "Flying Fish Cove",
+ "ascii": "Flying Fish Cove",
+ "country": "Christmas Island",
+ "timezone": "Indian/Christmas"
+ },
+ {
+ "id": "3570675",
+ "name": "Fort-de-France",
+ "ascii": "Fort-de-France",
+ "country": "Martinique",
+ "timezone": "America/Martinique"
+ },
+ {
+ "id": "3399415",
+ "name": "Fortaleza",
+ "ascii": "Fortaleza",
+ "country": "Brazil",
+ "timezone": "America/Fortaleza"
+ },
+ {
+ "id": "1811103",
+ "name": "Foshan",
+ "ascii": "Foshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "933778",
+ "name": "Francistown",
+ "ascii": "Francistown",
+ "country": "Botswana",
+ "timezone": "Africa/Gaborone"
+ },
+ {
+ "id": "2409306",
+ "name": "Freetown",
+ "ascii": "Freetown",
+ "country": "Sierra Leone",
+ "timezone": "Africa/Freetown"
+ },
+ {
+ "id": "2110394",
+ "name": "Funafuti",
+ "ascii": "Funafuti",
+ "country": "Tuvalu",
+ "timezone": "Pacific/Funafuti"
+ },
+ {
+ "id": "1810845",
+ "name": "Fuyang",
+ "ascii": "Fuyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1810821",
+ "name": "Fuzhou",
+ "ascii": "Fuzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "933773",
+ "name": "Gaborone",
+ "ascii": "Gaborone",
+ "country": "Botswana",
+ "timezone": "Africa/Gaborone"
+ },
+ {
+ "id": "2372532",
+ "name": "Gabú",
+ "ascii": "Gabu",
+ "country": "Guinea-Bissau",
+ "timezone": "Africa/Bissau"
+ },
+ {
+ "id": "1810638",
+ "name": "Ganzhou",
+ "ascii": "Ganzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "281133",
+ "name": "Gaza",
+ "ascii": "Gaza",
+ "country": "Palestinian Territory",
+ "timezone": "Asia/Gaza"
+ },
+ {
+ "id": "314830",
+ "name": "Gaziantep",
+ "ascii": "Gaziantep",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "1200109",
+ "name": "Gazipur",
+ "ascii": "Gazipur",
+ "country": "Bangladesh",
+ "timezone": "Asia/Dhaka"
+ },
+ {
+ "id": "2277060",
+ "name": "Gbarnga",
+ "ascii": "Gbarnga",
+ "country": "Liberia",
+ "timezone": "Africa/Monrovia"
+ },
+ {
+ "id": "2660646",
+ "name": "Geneva",
+ "ascii": "Geneva",
+ "country": "Switzerland",
+ "timezone": "Europe/Zurich"
+ },
+ {
+ "id": "3580661",
+ "name": "George Town",
+ "ascii": "George Town",
+ "country": "Cayman Islands",
+ "timezone": "America/Cayman"
+ },
+ {
+ "id": "3378644",
+ "name": "Georgetown",
+ "ascii": "Georgetown",
+ "country": "Guyana",
+ "timezone": "America/Guyana"
+ },
+ {
+ "id": "2411585",
+ "name": "Gibraltar",
+ "ascii": "Gibraltar",
+ "country": "Gibraltar",
+ "timezone": "Europe/Gibraltar"
+ },
+ {
+ "id": "202905",
+ "name": "Gisenyi",
+ "ascii": "Gisenyi",
+ "country": "Rwanda",
+ "timezone": "Africa/Kigali"
+ },
+ {
+ "id": "426272",
+ "name": "Gitega",
+ "ascii": "Gitega",
+ "country": "Burundi",
+ "timezone": "Africa/Bujumbura"
+ },
+ {
+ "id": "360995",
+ "name": "Giza",
+ "ascii": "Giza",
+ "country": "Egypt",
+ "timezone": "Africa/Cairo"
+ },
+ {
+ "id": "2711537",
+ "name": "Gothenburg",
+ "ascii": "Gothenburg",
+ "country": "Sweden",
+ "timezone": "Europe/Stockholm"
+ },
+ {
+ "id": "2778067",
+ "name": "Graz",
+ "ascii": "Graz",
+ "country": "Austria",
+ "timezone": "Europe/Vienna"
+ },
+ {
+ "id": "3576686",
+ "name": "Gros Islet",
+ "ascii": "Gros Islet",
+ "country": "Saint Lucia",
+ "timezone": "America/St_Lucia"
+ },
+ {
+ "id": "3426466",
+ "name": "Grytviken",
+ "ascii": "Grytviken",
+ "country": "South Georgia and the South Sandwich Islands",
+ "timezone": "Atlantic/South_Georgia"
+ },
+ {
+ "id": "1809858",
+ "name": "Guangzhou",
+ "ascii": "Guangzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3598132",
+ "name": "Guatemala City",
+ "ascii": "Guatemala City",
+ "country": "Guatemala",
+ "timezone": "America/Guatemala"
+ },
+ {
+ "id": "3657509",
+ "name": "Guayaquil",
+ "ascii": "Guayaquil",
+ "country": "Ecuador",
+ "timezone": "America/Guayaquil"
+ },
+ {
+ "id": "1809461",
+ "name": "Guiyang",
+ "ascii": "Guiyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1177662",
+ "name": "Gujranwala",
+ "ascii": "Gujranwala",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "3579132",
+ "name": "Gustavia",
+ "ascii": "Gustavia",
+ "country": "Saint Barthelemy",
+ "timezone": "America/St_Barthelemy"
+ },
+ {
+ "id": "616635",
+ "name": "Gyumri",
+ "ascii": "Gyumri",
+ "country": "Armenia",
+ "timezone": "Asia/Yerevan"
+ },
+ {
+ "id": "1809078",
+ "name": "Haikou",
+ "ascii": "Haikou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1581298",
+ "name": "Haiphong",
+ "ascii": "Haiphong",
+ "country": "Vietnam",
+ "timezone": "Asia/Bangkok"
+ },
+ {
+ "id": "2911298",
+ "name": "Hamburg",
+ "ascii": "Hamburg",
+ "country": "Germany",
+ "timezone": "Europe/Berlin"
+ },
+ {
+ "id": "1877449",
+ "name": "Hamhŭng",
+ "ascii": "Hamhung",
+ "country": "North Korea",
+ "timezone": "Asia/Pyongyang"
+ },
+ {
+ "id": "3573197",
+ "name": "Hamilton",
+ "ascii": "Hamilton",
+ "country": "Bermuda",
+ "timezone": "Atlantic/Bermuda"
+ },
+ {
+ "id": "1808926",
+ "name": "Hangzhou",
+ "ascii": "Hangzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1581130",
+ "name": "Hanoi",
+ "ascii": "Hanoi",
+ "country": "Vietnam",
+ "timezone": "Asia/Bangkok"
+ },
+ {
+ "id": "890299",
+ "name": "Harare",
+ "ascii": "Harare",
+ "country": "Zimbabwe",
+ "timezone": "Africa/Harare"
+ },
+ {
+ "id": "2037013",
+ "name": "Harbin",
+ "ascii": "Harbin",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3553478",
+ "name": "Havana",
+ "ascii": "Havana",
+ "country": "Cuba",
+ "timezone": "America/Havana"
+ },
+ {
+ "id": "285629",
+ "name": "Ḩawallī",
+ "ascii": "Hawalli",
+ "country": "Kuwait",
+ "timezone": "Asia/Kuwait"
+ },
+ {
+ "id": "1808722",
+ "name": "Hefei",
+ "ascii": "Hefei",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "658225",
+ "name": "Helsinki",
+ "ascii": "Helsinki",
+ "country": "Finland",
+ "timezone": "Europe/Helsinki"
+ },
+ {
+ "id": "1140026",
+ "name": "Herāt",
+ "ascii": "Herat",
+ "country": "Afghanistan",
+ "timezone": "Asia/Kabul"
+ },
+ {
+ "id": "1566083",
+ "name": "Ho Chi Minh City",
+ "ascii": "Ho Chi Minh City",
+ "country": "Vietnam",
+ "timezone": "Asia/Ho_Chi_Minh"
+ },
+ {
+ "id": "2036892",
+ "name": "Hohhot",
+ "ascii": "Hohhot",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "627907",
+ "name": "Homyel'",
+ "ascii": "Homyel'",
+ "country": "Belarus",
+ "timezone": "Europe/Minsk"
+ },
+ {
+ "id": "1819729",
+ "name": "Hong Kong",
+ "ascii": "Hong Kong",
+ "country": "Hong Kong",
+ "timezone": "Asia/Hong_Kong"
+ },
+ {
+ "id": "2108502",
+ "name": "Honiara",
+ "ascii": "Honiara",
+ "country": "Solomon Islands",
+ "timezone": "Pacific/Guadalcanal"
+ },
+ {
+ "id": "4699066",
+ "name": "Houston",
+ "ascii": "Houston",
+ "country": "United States",
+ "timezone": "America/Chicago"
+ },
+ {
+ "id": "1797873",
+ "name": "Huai'an",
+ "ascii": "Huai'an",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1807681",
+ "name": "Huainan",
+ "ascii": "Huainan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1806776",
+ "name": "Huizhou",
+ "ascii": "Huizhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1269843",
+ "name": "Hyderabad",
+ "ascii": "Hyderabad",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1176734",
+ "name": "Hyderabad",
+ "ascii": "Hyderabad",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "675810",
+ "name": "Iaşi",
+ "ascii": "Iasi",
+ "country": "Romania",
+ "timezone": "Europe/Bucharest"
+ },
+ {
+ "id": "2339354",
+ "name": "Ibadan",
+ "ascii": "Ibadan",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "1843564",
+ "name": "Incheon",
+ "ascii": "Incheon",
+ "country": "South Korea",
+ "timezone": "Asia/Seoul"
+ },
+ {
+ "id": "1269743",
+ "name": "Indore",
+ "ascii": "Indore",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1514896",
+ "name": "Isfara",
+ "ascii": "Isfara",
+ "country": "Tajikistan",
+ "timezone": "Asia/Dushanbe"
+ },
+ {
+ "id": "745044",
+ "name": "Istanbul",
+ "ascii": "Istanbul",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "311046",
+ "name": "İzmir",
+ "ascii": "Izmir",
+ "country": "Turkey",
+ "timezone": "Europe/Istanbul"
+ },
+ {
+ "id": "3526683",
+ "name": "Iztapalapa",
+ "ascii": "Iztapalapa",
+ "country": "Mexico",
+ "timezone": "America/Mexico_City"
+ },
+ {
+ "id": "1269515",
+ "name": "Jaipur",
+ "ascii": "Jaipur",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1642911",
+ "name": "Jakarta",
+ "ascii": "Jakarta",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "3370903",
+ "name": "Jamestown",
+ "ascii": "Jamestown",
+ "country": "Saint Helena",
+ "timezone": "Atlantic/St_Helena"
+ },
+ {
+ "id": "105343",
+ "name": "Jeddah",
+ "ascii": "Jeddah",
+ "country": "Saudi Arabia",
+ "timezone": "Asia/Riyadh"
+ },
+ {
+ "id": "281184",
+ "name": "Jerusalem",
+ "ascii": "Jerusalem",
+ "country": "Israel",
+ "timezone": "Asia/Jerusalem"
+ },
+ {
+ "id": "1806508",
+ "name": "Jiading",
+ "ascii": "Jiading",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1806299",
+ "name": "Jiangmen",
+ "ascii": "Jiangmen",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1815251",
+ "name": "Jiangyin",
+ "ascii": "Jiangyin",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1797121",
+ "name": "Jieyang",
+ "ascii": "Jieyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "333795",
+ "name": "Jijiga",
+ "ascii": "Jijiga",
+ "country": "Ethiopia",
+ "timezone": "Africa/Addis_Ababa"
+ },
+ {
+ "id": "2036502",
+ "name": "Jilin",
+ "ascii": "Jilin",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1805753",
+ "name": "Jinan",
+ "ascii": "Jinan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "993800",
+ "name": "Johannesburg",
+ "ascii": "Johannesburg",
+ "country": "South Africa",
+ "timezone": "Africa/Johannesburg"
+ },
+ {
+ "id": "1732752",
+ "name": "Johor Bahru",
+ "ascii": "Johor Bahru",
+ "country": "Malaysia",
+ "timezone": "Asia/Kuala_Lumpur"
+ },
+ {
+ "id": "373303",
+ "name": "Juba",
+ "ascii": "Juba",
+ "country": "South Sudan",
+ "timezone": "Africa/Juba"
+ },
+ {
+ "id": "1138958",
+ "name": "Kabul",
+ "ascii": "Kabul",
+ "country": "Afghanistan",
+ "timezone": "Asia/Kabul"
+ },
+ {
+ "id": "2335727",
+ "name": "Kaduna",
+ "ascii": "Kaduna",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "195272",
+ "name": "Kakamega",
+ "ascii": "Kakamega",
+ "country": "Kenya",
+ "timezone": "Africa/Nairobi"
+ },
+ {
+ "id": "232422",
+ "name": "Kampala",
+ "ascii": "Kampala",
+ "country": "Uganda",
+ "timezone": "Africa/Kampala"
+ },
+ {
+ "id": "2335204",
+ "name": "Kano",
+ "ascii": "Kano",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "1267995",
+ "name": "Kanpur",
+ "ascii": "Kanpur",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1673820",
+ "name": "Kaohsiung",
+ "ascii": "Kaohsiung",
+ "country": "Taiwan",
+ "timezone": "Asia/Taipei"
+ },
+ {
+ "id": "1174872",
+ "name": "Karachi",
+ "ascii": "Karachi",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "1283240",
+ "name": "Kathmandu",
+ "ascii": "Kathmandu",
+ "country": "Nepal",
+ "timezone": "Asia/Kathmandu"
+ },
+ {
+ "id": "598316",
+ "name": "Kaunas",
+ "ascii": "Kaunas",
+ "country": "Lithuania",
+ "timezone": "Europe/Vilnius"
+ },
+ {
+ "id": "2407790",
+ "name": "Kenema",
+ "ascii": "Kenema",
+ "country": "Sierra Leone",
+ "timezone": "Africa/Freetown"
+ },
+ {
+ "id": "333287",
+ "name": "Keren",
+ "ascii": "Keren",
+ "country": "Eritrea",
+ "timezone": "Africa/Asmara"
+ },
+ {
+ "id": "281124",
+ "name": "Khān Yūnis",
+ "ascii": "Khan Yunis",
+ "country": "Palestinian Territory",
+ "timezone": "Asia/Gaza"
+ },
+ {
+ "id": "706483",
+ "name": "Kharkiv",
+ "ascii": "Kharkiv",
+ "country": "Ukraine",
+ "timezone": "Europe/Kyiv"
+ },
+ {
+ "id": "379252",
+ "name": "Khartoum",
+ "ascii": "Khartoum",
+ "country": "Sudan",
+ "timezone": "Africa/Khartoum"
+ },
+ {
+ "id": "202061",
+ "name": "Kigali",
+ "ascii": "Kigali",
+ "country": "Rwanda",
+ "timezone": "Africa/Kigali"
+ },
+ {
+ "id": "3489854",
+ "name": "Kingston",
+ "ascii": "Kingston",
+ "country": "Jamaica",
+ "timezone": "America/Jamaica"
+ },
+ {
+ "id": "2161314",
+ "name": "Kingston",
+ "ascii": "Kingston",
+ "country": "Norfolk Island",
+ "timezone": "Pacific/Norfolk"
+ },
+ {
+ "id": "3577887",
+ "name": "Kingstown",
+ "ascii": "Kingstown",
+ "country": "Saint Vincent and the Grenadines",
+ "timezone": "America/St_Vincent"
+ },
+ {
+ "id": "2314302",
+ "name": "Kinshasa",
+ "ascii": "Kinshasa",
+ "country": "Democratic Republic of the Congo",
+ "timezone": "Africa/Kinshasa"
+ },
+ {
+ "id": "911148",
+ "name": "Kitwe",
+ "ascii": "Kitwe",
+ "country": "Zambia",
+ "timezone": "Africa/Lusaka"
+ },
+ {
+ "id": "1275004",
+ "name": "Kolkata",
+ "ascii": "Kolkata",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "3415212",
+ "name": "Kópavogur",
+ "ascii": "Kopavogur",
+ "country": "Iceland",
+ "timezone": "Atlantic/Reykjavik"
+ },
+ {
+ "id": "724443",
+ "name": "Košice",
+ "ascii": "Kosice",
+ "country": "Slovakia",
+ "timezone": "Europe/Bratislava"
+ },
+ {
+ "id": "1090225",
+ "name": "Koungou",
+ "ascii": "Koungou",
+ "country": "Mayotte",
+ "timezone": "Indian/Mayotte"
+ },
+ {
+ "id": "3094802",
+ "name": "Kraków",
+ "ascii": "Krakow",
+ "country": "Poland",
+ "timezone": "Europe/Warsaw"
+ },
+ {
+ "id": "3513563",
+ "name": "Kralendijk",
+ "ascii": "Kralendijk",
+ "country": "Bonaire, Saint Eustatius and Saba ",
+ "timezone": "America/Kralendijk"
+ },
+ {
+ "id": "1735161",
+ "name": "Kuala Lumpur",
+ "ascii": "Kuala Lumpur",
+ "country": "Malaysia",
+ "timezone": "Asia/Kuala_Lumpur"
+ },
+ {
+ "id": "788886",
+ "name": "Kumanovo",
+ "ascii": "Kumanovo",
+ "country": "North Macedonia",
+ "timezone": "Europe/Skopje"
+ },
+ {
+ "id": "2298890",
+ "name": "Kumasi",
+ "ascii": "Kumasi",
+ "country": "Ghana",
+ "timezone": "Africa/Accra"
+ },
+ {
+ "id": "1804651",
+ "name": "Kunming",
+ "ascii": "Kunming",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1785623",
+ "name": "Kunshan",
+ "ascii": "Kunshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "703448",
+ "name": "Kyiv",
+ "ascii": "Kyiv",
+ "country": "Ukraine",
+ "timezone": "Europe/Kyiv"
+ },
+ {
+ "id": "2462881",
+ "name": "Laayoune",
+ "ascii": "Laayoune",
+ "country": "Western Sahara",
+ "timezone": "Africa/El_Aaiun"
+ },
+ {
+ "id": "2092740",
+ "name": "Lae",
+ "ascii": "Lae",
+ "country": "Papua New Guinea",
+ "timezone": "Pacific/Port_Moresby"
+ },
+ {
+ "id": "2332459",
+ "name": "Lagos",
+ "ascii": "Lagos",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "1172451",
+ "name": "Lahore",
+ "ascii": "Lahore",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "1804430",
+ "name": "Lanzhou",
+ "ascii": "Lanzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3570428",
+ "name": "Le Lamentin",
+ "ascii": "Le Lamentin",
+ "country": "Martinique",
+ "timezone": "America/Martinique"
+ },
+ {
+ "id": "3383714",
+ "name": "Lelydorp",
+ "ascii": "Lelydorp",
+ "country": "Suriname",
+ "timezone": "America/Paramaribo"
+ },
+ {
+ "id": "3618030",
+ "name": "León",
+ "ascii": "Leon",
+ "country": "Nicaragua",
+ "timezone": "America/Managua"
+ },
+ {
+ "id": "3578959",
+ "name": "Les Abymes",
+ "ascii": "Les Abymes",
+ "country": "Guadeloupe",
+ "timezone": "America/Guadeloupe"
+ },
+ {
+ "id": "3040051",
+ "name": "les Escaldes",
+ "ascii": "les Escaldes",
+ "country": "Andorra",
+ "timezone": "Europe/Andorra"
+ },
+ {
+ "id": "10859300",
+ "name": "Lianyungang",
+ "ascii": "Lianyungang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2399697",
+ "name": "Libreville",
+ "ascii": "Libreville",
+ "country": "Gabon",
+ "timezone": "Africa/Libreville"
+ },
+ {
+ "id": "927967",
+ "name": "Lilongwe",
+ "ascii": "Lilongwe",
+ "country": "Malawi",
+ "timezone": "Africa/Blantyre"
+ },
+ {
+ "id": "3936456",
+ "name": "Lima",
+ "ascii": "Lima",
+ "country": "Peru",
+ "timezone": "America/Lima"
+ },
+ {
+ "id": "146384",
+ "name": "Limassol",
+ "ascii": "Limassol",
+ "country": "Cyprus",
+ "timezone": "Asia/Nicosia"
+ },
+ {
+ "id": "3622247",
+ "name": "Limón",
+ "ascii": "Limon",
+ "country": "Costa Rica",
+ "timezone": "America/Costa_Rica"
+ },
+ {
+ "id": "3377408",
+ "name": "Linden",
+ "ascii": "Linden",
+ "country": "Guyana",
+ "timezone": "America/Guyana"
+ },
+ {
+ "id": "1803318",
+ "name": "Linyi",
+ "ascii": "Linyi",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2267057",
+ "name": "Lisbon",
+ "ascii": "Lisbon",
+ "country": "Portugal",
+ "timezone": "Europe/Lisbon"
+ },
+ {
+ "id": "3196359",
+ "name": "Ljubljana",
+ "ascii": "Ljubljana",
+ "country": "Slovenia",
+ "timezone": "Europe/Ljubljana"
+ },
+ {
+ "id": "2365267",
+ "name": "Lomé",
+ "ascii": "Lome",
+ "country": "Togo",
+ "timezone": "Africa/Lome"
+ },
+ {
+ "id": "2643743",
+ "name": "London",
+ "ascii": "London",
+ "country": "United Kingdom",
+ "timezone": "Europe/London"
+ },
+ {
+ "id": "2729907",
+ "name": "Longyearbyen",
+ "ascii": "Longyearbyen",
+ "country": "Svalbard and Jan Mayen",
+ "timezone": "Arctic/Longyearbyen"
+ },
+ {
+ "id": "5368361",
+ "name": "Los Angeles",
+ "ascii": "Los Angeles",
+ "country": "United States",
+ "timezone": "America/Los_Angeles"
+ },
+ {
+ "id": "2240449",
+ "name": "Luanda",
+ "ascii": "Luanda",
+ "country": "Angola",
+ "timezone": "Africa/Luanda"
+ },
+ {
+ "id": "922704",
+ "name": "Lubumbashi",
+ "ascii": "Lubumbashi",
+ "country": "Democratic Republic of the Congo",
+ "timezone": "Africa/Lubumbashi"
+ },
+ {
+ "id": "3571971",
+ "name": "Lucaya",
+ "ascii": "Lucaya",
+ "country": "Bahamas",
+ "timezone": "America/Nassau"
+ },
+ {
+ "id": "1264733",
+ "name": "Lucknow",
+ "ascii": "Lucknow",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "13512505",
+ "name": "Lüliang",
+ "ascii": "Lueliang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "909137",
+ "name": "Lusaka",
+ "ascii": "Lusaka",
+ "country": "Zambia",
+ "timezone": "Africa/Lusaka"
+ },
+ {
+ "id": "2960316",
+ "name": "Luxembourg",
+ "ascii": "Luxembourg",
+ "country": "Luxembourg",
+ "timezone": "Europe/Luxembourg"
+ },
+ {
+ "id": "1821274",
+ "name": "Macau",
+ "ascii": "Macau",
+ "country": "Macao",
+ "timezone": "Asia/Macau"
+ },
+ {
+ "id": "3117735",
+ "name": "Madrid",
+ "ascii": "Madrid",
+ "country": "Spain",
+ "timezone": "Europe/Madrid"
+ },
+ {
+ "id": "2113779",
+ "name": "Majuro",
+ "ascii": "Majuro",
+ "country": "Marshall Islands",
+ "timezone": "Pacific/Majuro"
+ },
+ {
+ "id": "2309527",
+ "name": "Malabo",
+ "ascii": "Malabo",
+ "country": "Equatorial Guinea",
+ "timezone": "Africa/Malabo"
+ },
+ {
+ "id": "1282027",
+ "name": "Male",
+ "ascii": "Male",
+ "country": "Maldives",
+ "timezone": "Indian/Maldives"
+ },
+ {
+ "id": "1636670",
+ "name": "Maliana",
+ "ascii": "Maliana",
+ "country": "Timor Leste",
+ "timezone": "Asia/Dili"
+ },
+ {
+ "id": "921815",
+ "name": "Mamoudzou",
+ "ascii": "Mamoudzou",
+ "country": "Mayotte",
+ "timezone": "Indian/Mayotte"
+ },
+ {
+ "id": "3617763",
+ "name": "Managua",
+ "ascii": "Managua",
+ "country": "Nicaragua",
+ "timezone": "America/Managua"
+ },
+ {
+ "id": "290340",
+ "name": "Manama",
+ "ascii": "Manama",
+ "country": "Bahrain",
+ "timezone": "Asia/Bahrain"
+ },
+ {
+ "id": "3663517",
+ "name": "Manaus",
+ "ascii": "Manaus",
+ "country": "Brazil",
+ "timezone": "America/Manaus"
+ },
+ {
+ "id": "1311874",
+ "name": "Mandalay",
+ "ascii": "Mandalay",
+ "country": "Myanmar",
+ "timezone": "Asia/Yangon"
+ },
+ {
+ "id": "934995",
+ "name": "Manzini",
+ "ascii": "Manzini",
+ "country": "Eswatini",
+ "timezone": "Africa/Mbabane"
+ },
+ {
+ "id": "1040652",
+ "name": "Maputo",
+ "ascii": "Maputo",
+ "country": "Mozambique",
+ "timezone": "Africa/Maputo"
+ },
+ {
+ "id": "932521",
+ "name": "Maputsoe",
+ "ascii": "Maputsoe",
+ "country": "Lesotho",
+ "timezone": "Africa/Maseru"
+ },
+ {
+ "id": "3633009",
+ "name": "Maracaibo",
+ "ascii": "Maracaibo",
+ "country": "Venezuela",
+ "timezone": "America/Caracas"
+ },
+ {
+ "id": "2441291",
+ "name": "Maradi",
+ "ascii": "Maradi",
+ "country": "Niger",
+ "timezone": "Africa/Niamey"
+ },
+ {
+ "id": "3195506",
+ "name": "Maribor",
+ "ascii": "Maribor",
+ "country": "Slovenia",
+ "timezone": "Europe/Ljubljana"
+ },
+ {
+ "id": "3041732",
+ "name": "Mariehamn",
+ "ascii": "Mariehamn",
+ "country": "Aland Islands",
+ "timezone": "Europe/Mariehamn"
+ },
+ {
+ "id": "3578851",
+ "name": "Marigot",
+ "ascii": "Marigot",
+ "country": "Saint Martin",
+ "timezone": "America/Marigot"
+ },
+ {
+ "id": "2995469",
+ "name": "Marseille",
+ "ascii": "Marseille",
+ "country": "France",
+ "timezone": "Europe/Paris"
+ },
+ {
+ "id": "932505",
+ "name": "Maseru",
+ "ascii": "Maseru",
+ "country": "Lesotho",
+ "timezone": "Africa/Maseru"
+ },
+ {
+ "id": "124665",
+ "name": "Mashhad",
+ "ascii": "Mashhad",
+ "country": "Iran",
+ "timezone": "Asia/Tehran"
+ },
+ {
+ "id": "4034821",
+ "name": "Mata-Utu",
+ "ascii": "Mata-Utu",
+ "country": "Wallis and Futuna",
+ "timezone": "Pacific/Wallis"
+ },
+ {
+ "id": "1039854",
+ "name": "Matola",
+ "ascii": "Matola",
+ "country": "Mozambique",
+ "timezone": "Africa/Maputo"
+ },
+ {
+ "id": "3380965",
+ "name": "Matoury",
+ "ascii": "Matoury",
+ "country": "French Guiana",
+ "timezone": "America/Cayenne"
+ },
+ {
+ "id": "934985",
+ "name": "Mbabane",
+ "ascii": "Mbabane",
+ "country": "Eswatini",
+ "timezone": "Africa/Mbabane"
+ },
+ {
+ "id": "209228",
+ "name": "Mbuji-Mayi",
+ "ascii": "Mbuji-Mayi",
+ "country": "Democratic Republic of the Congo",
+ "timezone": "Africa/Lubumbashi"
+ },
+ {
+ "id": "1214520",
+ "name": "Medan",
+ "ascii": "Medan",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "3674962",
+ "name": "Medellín",
+ "ascii": "Medellin",
+ "country": "Colombia",
+ "timezone": "America/Bogota"
+ },
+ {
+ "id": "2158177",
+ "name": "Melbourne",
+ "ascii": "Melbourne",
+ "country": "Australia",
+ "timezone": "Australia/Melbourne"
+ },
+ {
+ "id": "3530597",
+ "name": "Mexico City",
+ "ascii": "Mexico City",
+ "country": "Mexico",
+ "timezone": "America/Mexico_City"
+ },
+ {
+ "id": "3173435",
+ "name": "Milan",
+ "ascii": "Milan",
+ "country": "Italy",
+ "timezone": "Europe/Rome"
+ },
+ {
+ "id": "3374462",
+ "name": "Mindelo",
+ "ascii": "Mindelo",
+ "country": "Cabo Verde",
+ "timezone": "Atlantic/Cape_Verde"
+ },
+ {
+ "id": "1800480",
+ "name": "Minhang",
+ "ascii": "Minhang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "625144",
+ "name": "Minsk",
+ "ascii": "Minsk",
+ "country": "Belarus",
+ "timezone": "Europe/Minsk"
+ },
+ {
+ "id": "53654",
+ "name": "Mogadishu",
+ "ascii": "Mogadishu",
+ "country": "Somalia",
+ "timezone": "Africa/Mogadishu"
+ },
+ {
+ "id": "3574116",
+ "name": "Mon Repos",
+ "ascii": "Mon Repos",
+ "country": "Trinidad and Tobago",
+ "timezone": "America/Port_of_Spain"
+ },
+ {
+ "id": "2993458",
+ "name": "Monaco",
+ "ascii": "Monaco",
+ "country": "Monaco",
+ "timezone": "Europe/Monaco"
+ },
+ {
+ "id": "2274895",
+ "name": "Monrovia",
+ "ascii": "Monrovia",
+ "country": "Liberia",
+ "timezone": "Africa/Monrovia"
+ },
+ {
+ "id": "2140066",
+ "name": "Mont-Dore",
+ "ascii": "Mont-Dore",
+ "country": "New Caledonia",
+ "timezone": "Pacific/Noumea"
+ },
+ {
+ "id": "3441575",
+ "name": "Montevideo",
+ "ascii": "Montevideo",
+ "country": "Uruguay",
+ "timezone": "America/Montevideo"
+ },
+ {
+ "id": "6077243",
+ "name": "Montréal",
+ "ascii": "Montreal",
+ "country": "Canada",
+ "timezone": "America/Toronto"
+ },
+ {
+ "id": "921772",
+ "name": "Moroni",
+ "ascii": "Moroni",
+ "country": "Comoros",
+ "timezone": "Indian/Comoro"
+ },
+ {
+ "id": "524901",
+ "name": "Moscow",
+ "ascii": "Moscow",
+ "country": "Russia",
+ "timezone": "Europe/Moscow"
+ },
+ {
+ "id": "99072",
+ "name": "Mosul",
+ "ascii": "Mosul",
+ "country": "Iraq",
+ "timezone": "Asia/Baghdad"
+ },
+ {
+ "id": "2427455",
+ "name": "Moundou",
+ "ascii": "Moundou",
+ "country": "Chad",
+ "timezone": "Africa/Ndjamena"
+ },
+ {
+ "id": "921753",
+ "name": "Moutsamoudou",
+ "ascii": "Moutsamoudou",
+ "country": "Comoros",
+ "timezone": "Indian/Comoro"
+ },
+ {
+ "id": "8307074",
+ "name": "Mulenvos",
+ "ascii": "Mulenvos",
+ "country": "Angola",
+ "timezone": "Africa/Luanda"
+ },
+ {
+ "id": "1169825",
+ "name": "Multan",
+ "ascii": "Multan",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "1275339",
+ "name": "Mumbai",
+ "ascii": "Mumbai",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "287286",
+ "name": "Muscat",
+ "ascii": "Muscat",
+ "country": "Oman",
+ "timezone": "Asia/Muscat"
+ },
+ {
+ "id": "152224",
+ "name": "Mwanza",
+ "ascii": "Mwanza",
+ "country": "Tanzania",
+ "timezone": "Africa/Dar_es_Salaam"
+ },
+ {
+ "id": "2427123",
+ "name": "N'Djamena",
+ "ascii": "N'Djamena",
+ "country": "Chad",
+ "timezone": "Africa/Ndjamena"
+ },
+ {
+ "id": "1856057",
+ "name": "Nagoya",
+ "ascii": "Nagoya",
+ "country": "Japan",
+ "timezone": "Asia/Tokyo"
+ },
+ {
+ "id": "1262180",
+ "name": "Nagpur",
+ "ascii": "Nagpur",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "184745",
+ "name": "Nairobi",
+ "ascii": "Nairobi",
+ "country": "Kenya",
+ "timezone": "Africa/Nairobi"
+ },
+ {
+ "id": "1800163",
+ "name": "Nanchang",
+ "ascii": "Nanchang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1800146",
+ "name": "Nanchong",
+ "ascii": "Nanchong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1799962",
+ "name": "Nanjing",
+ "ascii": "Nanjing",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1799869",
+ "name": "Nanning",
+ "ascii": "Nanning",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "10943053",
+ "name": "Nansana",
+ "ascii": "Nansana",
+ "country": "Uganda",
+ "timezone": "Africa/Kampala"
+ },
+ {
+ "id": "1799722",
+ "name": "Nantong",
+ "ascii": "Nantong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1799629",
+ "name": "Nanyang",
+ "ascii": "Nanyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "8740209",
+ "name": "Nasinu",
+ "ascii": "Nasinu",
+ "country": "Fiji",
+ "timezone": "Pacific/Fiji"
+ },
+ {
+ "id": "3571824",
+ "name": "Nassau",
+ "ascii": "Nassau",
+ "country": "Bahamas",
+ "timezone": "America/Nassau"
+ },
+ {
+ "id": "12908892",
+ "name": "New Taipei City",
+ "ascii": "New Taipei City",
+ "country": "Taiwan",
+ "timezone": "Asia/Taipei"
+ },
+ {
+ "id": "5128581",
+ "name": "New York City",
+ "ascii": "New York City",
+ "country": "United States",
+ "timezone": "America/New_York"
+ },
+ {
+ "id": "8063361",
+ "name": "Ngerulmud",
+ "ascii": "Ngerulmud",
+ "country": "Palau",
+ "timezone": "Pacific/Palau"
+ },
+ {
+ "id": "2440485",
+ "name": "Niamey",
+ "ascii": "Niamey",
+ "country": "Niger",
+ "timezone": "Africa/Niamey"
+ },
+ {
+ "id": "146268",
+ "name": "Nicosia",
+ "ascii": "Nicosia",
+ "country": "Cyprus",
+ "timezone": "Asia/Nicosia"
+ },
+ {
+ "id": "3194494",
+ "name": "Nikšić",
+ "ascii": "Niksic",
+ "country": "Montenegro",
+ "timezone": "Europe/Podgorica"
+ },
+ {
+ "id": "1799397",
+ "name": "Ningbo",
+ "ascii": "Ningbo",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "787657",
+ "name": "Niš",
+ "ascii": "Nis",
+ "country": "Serbia",
+ "timezone": "Europe/Belgrade"
+ },
+ {
+ "id": "3577159",
+ "name": "Noord",
+ "ascii": "Noord",
+ "country": "Aruba",
+ "timezone": "America/Aruba"
+ },
+ {
+ "id": "2377457",
+ "name": "Nouadhibou",
+ "ascii": "Nouadhibou",
+ "country": "Mauritania",
+ "timezone": "Africa/Nouakchott"
+ },
+ {
+ "id": "2377450",
+ "name": "Nouakchott",
+ "ascii": "Nouakchott",
+ "country": "Mauritania",
+ "timezone": "Africa/Nouakchott"
+ },
+ {
+ "id": "2139521",
+ "name": "Nouméa",
+ "ascii": "Noumea",
+ "country": "New Caledonia",
+ "timezone": "Pacific/Noumea"
+ },
+ {
+ "id": "4032402",
+ "name": "Nuku‘alofa",
+ "ascii": "Nuku'alofa",
+ "country": "Tonga",
+ "timezone": "Pacific/Tongatapu"
+ },
+ {
+ "id": "3421319",
+ "name": "Nuuk",
+ "ascii": "Nuuk",
+ "country": "Greenland",
+ "timezone": "America/Nuuk"
+ },
+ {
+ "id": "365137",
+ "name": "Omdurman",
+ "ascii": "Omdurman",
+ "country": "Sudan",
+ "timezone": "Africa/Khartoum"
+ },
+ {
+ "id": "2485926",
+ "name": "Oran",
+ "ascii": "Oran",
+ "country": "Algeria",
+ "timezone": "Africa/Algiers"
+ },
+ {
+ "id": "3577154",
+ "name": "Oranjestad",
+ "ascii": "Oranjestad",
+ "country": "Aruba",
+ "timezone": "America/Aruba"
+ },
+ {
+ "id": "8347664",
+ "name": "Ordos",
+ "ascii": "Ordos",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1853909",
+ "name": "Osaka",
+ "ascii": "Osaka",
+ "country": "Japan",
+ "timezone": "Asia/Tokyo"
+ },
+ {
+ "id": "1527534",
+ "name": "Osh",
+ "ascii": "Osh",
+ "country": "Kyrgyzstan",
+ "timezone": "Asia/Bishkek"
+ },
+ {
+ "id": "3143244",
+ "name": "Oslo",
+ "ascii": "Oslo",
+ "country": "Norway",
+ "timezone": "Europe/Oslo"
+ },
+ {
+ "id": "2357048",
+ "name": "Ouagadougou",
+ "ascii": "Ouagadougou",
+ "country": "Burkina Faso",
+ "timezone": "Africa/Ouagadougou"
+ },
+ {
+ "id": "5881576",
+ "name": "Pago Pago",
+ "ascii": "Pago Pago",
+ "country": "American Samoa",
+ "timezone": "Pacific/Pago_Pago"
+ },
+ {
+ "id": "1633070",
+ "name": "Palembang",
+ "ascii": "Palembang",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "2081986",
+ "name": "Palikir",
+ "ascii": "Palikir",
+ "country": "Micronesia",
+ "timezone": "Pacific/Pohnpei"
+ },
+ {
+ "id": "3703443",
+ "name": "Panama City",
+ "ascii": "Panama City",
+ "country": "Panama",
+ "timezone": "America/Panama"
+ },
+ {
+ "id": "4033936",
+ "name": "Papeete",
+ "ascii": "Papeete",
+ "country": "French Polynesia",
+ "timezone": "Pacific/Tahiti"
+ },
+ {
+ "id": "3383330",
+ "name": "Paramaribo",
+ "ascii": "Paramaribo",
+ "country": "Suriname",
+ "timezone": "America/Paramaribo"
+ },
+ {
+ "id": "2988507",
+ "name": "Paris",
+ "ascii": "Paris",
+ "country": "France",
+ "timezone": "Europe/Paris"
+ },
+ {
+ "id": "2063523",
+ "name": "Perth",
+ "ascii": "Perth",
+ "country": "Australia",
+ "timezone": "Australia/Perth"
+ },
+ {
+ "id": "1168197",
+ "name": "Peshawar",
+ "ascii": "Peshawar",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "3513392",
+ "name": "Philipsburg",
+ "ascii": "Philipsburg",
+ "country": "Sint Maarten",
+ "timezone": "America/Lower_Princes"
+ },
+ {
+ "id": "1821306",
+ "name": "Phnom Penh",
+ "ascii": "Phnom Penh",
+ "country": "Cambodia",
+ "timezone": "Asia/Phnom_Penh"
+ },
+ {
+ "id": "1252484",
+ "name": "Phuntsholing",
+ "ascii": "Phuntsholing",
+ "country": "Bhutan",
+ "timezone": "Asia/Thimphu"
+ },
+ {
+ "id": "2246678",
+ "name": "Pikine",
+ "ascii": "Pikine",
+ "country": "Senegal",
+ "timezone": "Africa/Dakar"
+ },
+ {
+ "id": "7626690",
+ "name": "Pimpri-Chinchwad",
+ "ascii": "Pimpri-Chinchwad",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "728193",
+ "name": "Plovdiv",
+ "ascii": "Plovdiv",
+ "country": "Bulgaria",
+ "timezone": "Europe/Sofia"
+ },
+ {
+ "id": "3578069",
+ "name": "Plymouth",
+ "ascii": "Plymouth",
+ "country": "Montserrat",
+ "timezone": "America/Montserrat"
+ },
+ {
+ "id": "3193044",
+ "name": "Podgorica",
+ "ascii": "Podgorica",
+ "country": "Montenegro",
+ "timezone": "Europe/Podgorica"
+ },
+ {
+ "id": "2255414",
+ "name": "Pointe-Noire",
+ "ascii": "Pointe-Noire",
+ "country": "Republic of the Congo",
+ "timezone": "Africa/Brazzaville"
+ },
+ {
+ "id": "1282898",
+ "name": "Pokhara",
+ "ascii": "Pokhara",
+ "country": "Nepal",
+ "timezone": "Asia/Kathmandu"
+ },
+ {
+ "id": "2324774",
+ "name": "Port Harcourt",
+ "ascii": "Port Harcourt",
+ "country": "Nigeria",
+ "timezone": "Africa/Lagos"
+ },
+ {
+ "id": "934154",
+ "name": "Port Louis",
+ "ascii": "Port Louis",
+ "country": "Mauritius",
+ "timezone": "Indian/Mauritius"
+ },
+ {
+ "id": "2088122",
+ "name": "Port Moresby",
+ "ascii": "Port Moresby",
+ "country": "Papua New Guinea",
+ "timezone": "Pacific/Port_Moresby"
+ },
+ {
+ "id": "3718426",
+ "name": "Port-au-Prince",
+ "ascii": "Port-au-Prince",
+ "country": "Haiti",
+ "timezone": "America/Port-au-Prince"
+ },
+ {
+ "id": "1546102",
+ "name": "Port-aux-Français",
+ "ascii": "Port-aux-Francais",
+ "country": "French Southern Territories",
+ "timezone": "Indian/Kerguelen"
+ },
+ {
+ "id": "2396518",
+ "name": "Port-Gentil",
+ "ascii": "Port-Gentil",
+ "country": "Gabon",
+ "timezone": "Africa/Libreville"
+ },
+ {
+ "id": "2135171",
+ "name": "Port-Vila",
+ "ascii": "Port-Vila",
+ "country": "Vanuatu",
+ "timezone": "Pacific/Efate"
+ },
+ {
+ "id": "2735943",
+ "name": "Porto",
+ "ascii": "Porto",
+ "country": "Portugal",
+ "timezone": "Europe/Lisbon"
+ },
+ {
+ "id": "2392087",
+ "name": "Porto-Novo",
+ "ascii": "Porto-Novo",
+ "country": "Benin",
+ "timezone": "Africa/Porto-Novo"
+ },
+ {
+ "id": "3067696",
+ "name": "Prague",
+ "ascii": "Prague",
+ "country": "Czechia",
+ "timezone": "Europe/Prague"
+ },
+ {
+ "id": "3374333",
+ "name": "Praia",
+ "ascii": "Praia",
+ "country": "Cabo Verde",
+ "timezone": "Atlantic/Cape_Verde"
+ },
+ {
+ "id": "964137",
+ "name": "Pretoria",
+ "ascii": "Pretoria",
+ "country": "South Africa",
+ "timezone": "Africa/Johannesburg"
+ },
+ {
+ "id": "786714",
+ "name": "Pristina",
+ "ascii": "Pristina",
+ "country": "Kosovo",
+ "timezone": "Europe/Belgrade"
+ },
+ {
+ "id": "786712",
+ "name": "Prizren",
+ "ascii": "Prizren",
+ "country": "Kosovo",
+ "timezone": "Europe/Belgrade"
+ },
+ {
+ "id": "11703857",
+ "name": "Providenciales",
+ "ascii": "Providenciales",
+ "country": "Turks and Caicos Islands",
+ "timezone": "America/Grand_Turk"
+ },
+ {
+ "id": "1798524",
+ "name": "Pudong",
+ "ascii": "Pudong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3875024",
+ "name": "Puente Alto",
+ "ascii": "Puente Alto",
+ "country": "Chile",
+ "timezone": "America/Santiago"
+ },
+ {
+ "id": "1259229",
+ "name": "Pune",
+ "ascii": "Pune",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1802940",
+ "name": "Puning",
+ "ascii": "Puning",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1798425",
+ "name": "Puyang",
+ "ascii": "Puyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1871859",
+ "name": "Pyongyang",
+ "ascii": "Pyongyang",
+ "country": "North Korea",
+ "timezone": "Asia/Pyongyang"
+ },
+ {
+ "id": "1797929",
+ "name": "Qingdao",
+ "ascii": "Qingdao",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "12359313",
+ "name": "Qingyang",
+ "ascii": "Qingyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1797945",
+ "name": "Qingyuan",
+ "ascii": "Qingyuan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "5133273",
+ "name": "Queens",
+ "ascii": "Queens",
+ "country": "United States",
+ "timezone": "America/New_York"
+ },
+ {
+ "id": "1692192",
+ "name": "Quezon City",
+ "ascii": "Quezon City",
+ "country": "Philippines",
+ "timezone": "Asia/Manila"
+ },
+ {
+ "id": "3652462",
+ "name": "Quito",
+ "ascii": "Quito",
+ "country": "Ecuador",
+ "timezone": "America/Guayaquil"
+ },
+ {
+ "id": "2538475",
+ "name": "Rabat",
+ "ascii": "Rabat",
+ "country": "Morocco",
+ "timezone": "Africa/Casablanca"
+ },
+ {
+ "id": "1258393",
+ "name": "Rasapūdipalem",
+ "ascii": "Rasapudipalem",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "1166993",
+ "name": "Rawalpindi",
+ "ascii": "Rawalpindi",
+ "country": "Pakistan",
+ "timezone": "Asia/Karachi"
+ },
+ {
+ "id": "3413829",
+ "name": "Reykjavík",
+ "ascii": "Reykjavik",
+ "country": "Iceland",
+ "timezone": "Atlantic/Reykjavik"
+ },
+ {
+ "id": "456172",
+ "name": "Riga",
+ "ascii": "Riga",
+ "country": "Latvia",
+ "timezone": "Europe/Riga"
+ },
+ {
+ "id": "3451190",
+ "name": "Rio de Janeiro",
+ "ascii": "Rio de Janeiro",
+ "country": "Brazil",
+ "timezone": "America/Sao_Paulo"
+ },
+ {
+ "id": "108410",
+ "name": "Riyadh",
+ "ascii": "Riyadh",
+ "country": "Saudi Arabia",
+ "timezone": "Asia/Riyadh"
+ },
+ {
+ "id": "3577430",
+ "name": "Road Town",
+ "ascii": "Road Town",
+ "country": "British Virgin Islands",
+ "timezone": "America/Tortola"
+ },
+ {
+ "id": "3169070",
+ "name": "Rome",
+ "ascii": "Rome",
+ "country": "Italy",
+ "timezone": "Europe/Rome"
+ },
+ {
+ "id": "3575635",
+ "name": "Roseau",
+ "ascii": "Roseau",
+ "country": "Dominica",
+ "timezone": "America/Dominica"
+ },
+ {
+ "id": "2747891",
+ "name": "Rotterdam",
+ "ascii": "Rotterdam",
+ "country": "The Netherlands",
+ "timezone": "Europe/Amsterdam"
+ },
+ {
+ "id": "3353383",
+ "name": "Rundu",
+ "ascii": "Rundu",
+ "country": "Namibia",
+ "timezone": "Africa/Windhoek"
+ },
+ {
+ "id": "4796512",
+ "name": "Saint Croix",
+ "ascii": "Saint Croix",
+ "country": "U.S. Virgin Islands",
+ "timezone": "America/St_Thomas"
+ },
+ {
+ "id": "3579925",
+ "name": "Saint George's",
+ "ascii": "Saint George's",
+ "country": "Grenada",
+ "timezone": "America/Grenada"
+ },
+ {
+ "id": "3042091",
+ "name": "Saint Helier",
+ "ascii": "Saint Helier",
+ "country": "Jersey",
+ "timezone": "Europe/Jersey"
+ },
+ {
+ "id": "3576022",
+ "name": "Saint John’s",
+ "ascii": "Saint John's",
+ "country": "Antigua and Barbuda",
+ "timezone": "America/Antigua"
+ },
+ {
+ "id": "3042287",
+ "name": "Saint Peter Port",
+ "ascii": "Saint Peter Port",
+ "country": "Guernsey",
+ "timezone": "Europe/Guernsey"
+ },
+ {
+ "id": "498817",
+ "name": "Saint Petersburg",
+ "ascii": "Saint Petersburg",
+ "country": "Russia",
+ "timezone": "Europe/Moscow"
+ },
+ {
+ "id": "935264",
+ "name": "Saint-Denis",
+ "ascii": "Saint-Denis",
+ "country": "Reunion",
+ "timezone": "Indian/Reunion"
+ },
+ {
+ "id": "935221",
+ "name": "Saint-Paul",
+ "ascii": "Saint-Paul",
+ "country": "Reunion",
+ "timezone": "Indian/Reunion"
+ },
+ {
+ "id": "3424934",
+ "name": "Saint-Pierre",
+ "ascii": "Saint-Pierre",
+ "country": "Saint Pierre and Miquelon",
+ "timezone": "America/Miquelon"
+ },
+ {
+ "id": "7828758",
+ "name": "Saipan",
+ "ascii": "Saipan",
+ "country": "Northern Mariana Islands",
+ "timezone": "Pacific/Saipan"
+ },
+ {
+ "id": "3440714",
+ "name": "Salto",
+ "ascii": "Salto",
+ "country": "Uruguay",
+ "timezone": "America/Montevideo"
+ },
+ {
+ "id": "3450554",
+ "name": "Salvador",
+ "ascii": "Salvador",
+ "country": "Brazil",
+ "timezone": "America/Bahia"
+ },
+ {
+ "id": "1606590",
+ "name": "Samut Prakan",
+ "ascii": "Samut Prakan",
+ "country": "Thailand",
+ "timezone": "Asia/Bangkok"
+ },
+ {
+ "id": "3581194",
+ "name": "San Ignacio",
+ "ascii": "San Ignacio",
+ "country": "Belize",
+ "timezone": "America/Belize"
+ },
+ {
+ "id": "3621849",
+ "name": "San José",
+ "ascii": "San Jose",
+ "country": "Costa Rica",
+ "timezone": "America/Costa_Rica"
+ },
+ {
+ "id": "4568127",
+ "name": "San Juan",
+ "ascii": "San Juan",
+ "country": "Puerto Rico",
+ "timezone": "America/Puerto_Rico"
+ },
+ {
+ "id": "3168070",
+ "name": "San Marino",
+ "ascii": "San Marino",
+ "country": "San Marino",
+ "timezone": "Europe/San_Marino"
+ },
+ {
+ "id": "3701329",
+ "name": "San Miguelito",
+ "ascii": "San Miguelito",
+ "country": "Panama",
+ "timezone": "America/Panama"
+ },
+ {
+ "id": "2562541",
+ "name": "San Pawl il-Baħar",
+ "ascii": "San Pawl il-Bahar",
+ "country": "Malta",
+ "timezone": "Europe/Malta"
+ },
+ {
+ "id": "3601782",
+ "name": "San Pedro Sula",
+ "ascii": "San Pedro Sula",
+ "country": "Honduras",
+ "timezone": "America/Tegucigalpa"
+ },
+ {
+ "id": "3583361",
+ "name": "San Salvador",
+ "ascii": "San Salvador",
+ "country": "El Salvador",
+ "timezone": "America/El_Salvador"
+ },
+ {
+ "id": "71137",
+ "name": "Sanaa",
+ "ascii": "Sanaa",
+ "country": "Yemen",
+ "timezone": "Asia/Aden"
+ },
+ {
+ "id": "3904906",
+ "name": "Santa Cruz de la Sierra",
+ "ascii": "Santa Cruz de la Sierra",
+ "country": "Bolivia",
+ "timezone": "America/La_Paz"
+ },
+ {
+ "id": "3871336",
+ "name": "Santiago",
+ "ascii": "Santiago",
+ "country": "Chile",
+ "timezone": "America/Santiago"
+ },
+ {
+ "id": "3536729",
+ "name": "Santiago de Cuba",
+ "ascii": "Santiago de Cuba",
+ "country": "Cuba",
+ "timezone": "America/Havana"
+ },
+ {
+ "id": "3492914",
+ "name": "Santiago de los Caballeros",
+ "ascii": "Santiago de los Caballeros",
+ "country": "Dominican Republic",
+ "timezone": "America/Santo_Domingo"
+ },
+ {
+ "id": "3492908",
+ "name": "Santo Domingo",
+ "ascii": "Santo Domingo",
+ "country": "Dominican Republic",
+ "timezone": "America/Santo_Domingo"
+ },
+ {
+ "id": "3448439",
+ "name": "São Paulo",
+ "ascii": "Sao Paulo",
+ "country": "Brazil",
+ "timezone": "America/Sao_Paulo"
+ },
+ {
+ "id": "2410763",
+ "name": "São Tomé",
+ "ascii": "Sao Tome",
+ "country": "Sao Tome and Principe",
+ "timezone": "Africa/Sao_Tome"
+ },
+ {
+ "id": "2128295",
+ "name": "Sapporo",
+ "ascii": "Sapporo",
+ "country": "Japan",
+ "timezone": "Asia/Tokyo"
+ },
+ {
+ "id": "3191281",
+ "name": "Sarajevo",
+ "ascii": "Sarajevo",
+ "country": "Bosnia and Herzegovina",
+ "timezone": "Europe/Sarajevo"
+ },
+ {
+ "id": "1653316",
+ "name": "Savannakhet",
+ "ascii": "Savannakhet",
+ "country": "Laos",
+ "timezone": "Asia/Vientiane"
+ },
+ {
+ "id": "288967",
+ "name": "Seeb",
+ "ascii": "Seeb",
+ "country": "Oman",
+ "timezone": "Asia/Muscat"
+ },
+ {
+ "id": "1627896",
+ "name": "Semarang",
+ "ascii": "Semarang",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "1820575",
+ "name": "Sengkurong",
+ "ascii": "Sengkurong",
+ "country": "Brunei",
+ "timezone": "Asia/Brunei"
+ },
+ {
+ "id": "1835848",
+ "name": "Seoul",
+ "ascii": "Seoul",
+ "country": "South Korea",
+ "timezone": "Asia/Seoul"
+ },
+ {
+ "id": "2411989",
+ "name": "Serekunda",
+ "ascii": "Serekunda",
+ "country": "Gambia",
+ "timezone": "Africa/Banjul"
+ },
+ {
+ "id": "2467454",
+ "name": "Sfax",
+ "ascii": "Sfax",
+ "country": "Tunisia",
+ "timezone": "Africa/Tunis"
+ },
+ {
+ "id": "1796236",
+ "name": "Shanghai",
+ "ascii": "Shanghai",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1796134",
+ "name": "Shangqiu",
+ "ascii": "Shangqiu",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1795940",
+ "name": "Shantou",
+ "ascii": "Shantou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1795855",
+ "name": "Shaoxing",
+ "ascii": "Shaoxing",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "292672",
+ "name": "Sharjah",
+ "ascii": "Sharjah",
+ "country": "United Arab Emirates",
+ "timezone": "Asia/Dubai"
+ },
+ {
+ "id": "2034937",
+ "name": "Shenyang",
+ "ascii": "Shenyang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1795565",
+ "name": "Shenzhen",
+ "ascii": "Shenzhen",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1795270",
+ "name": "Shijiazhuang",
+ "ascii": "Shijiazhuang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1794903",
+ "name": "Shiyan",
+ "ascii": "Shiyan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2451185",
+ "name": "Sikasso",
+ "ascii": "Sikasso",
+ "country": "Mali",
+ "timezone": "Africa/Bamako"
+ },
+ {
+ "id": "1880252",
+ "name": "Singapore",
+ "ascii": "Singapore",
+ "country": "Singapore",
+ "timezone": "Asia/Singapore"
+ },
+ {
+ "id": "785842",
+ "name": "Skopje",
+ "ascii": "Skopje",
+ "country": "North Macedonia",
+ "timezone": "Europe/Skopje"
+ },
+ {
+ "id": "727011",
+ "name": "Sofia",
+ "ascii": "Sofia",
+ "country": "Bulgaria",
+ "timezone": "Europe/Sofia"
+ },
+ {
+ "id": "2364104",
+ "name": "Sokodé",
+ "ascii": "Sokode",
+ "country": "Togo",
+ "timezone": "Africa/Lome"
+ },
+ {
+ "id": "1794035",
+ "name": "Songjiang",
+ "ascii": "Songjiang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "953781",
+ "name": "Soweto",
+ "ascii": "Soweto",
+ "country": "South Africa",
+ "timezone": "Africa/Johannesburg"
+ },
+ {
+ "id": "3583096",
+ "name": "Soyapango",
+ "ascii": "Soyapango",
+ "country": "El Salvador",
+ "timezone": "America/El_Salvador"
+ },
+ {
+ "id": "3488465",
+ "name": "Spanish Town",
+ "ascii": "Spanish Town",
+ "country": "Jamaica",
+ "timezone": "America/Jamaica"
+ },
+ {
+ "id": "3190261",
+ "name": "Split",
+ "ascii": "Split",
+ "country": "Croatia",
+ "timezone": "Europe/Zagreb"
+ },
+ {
+ "id": "3426691",
+ "name": "Stanley",
+ "ascii": "Stanley",
+ "country": "Falkland Islands",
+ "timezone": "Atlantic/Stanley"
+ },
+ {
+ "id": "2673730",
+ "name": "Stockholm",
+ "ascii": "Stockholm",
+ "country": "Sweden",
+ "timezone": "Europe/Stockholm"
+ },
+ {
+ "id": "584923",
+ "name": "Sumgayit",
+ "ascii": "Sumgayit",
+ "country": "Azerbaijan",
+ "timezone": "Asia/Baku"
+ },
+ {
+ "id": "1625822",
+ "name": "Surabaya",
+ "ascii": "Surabaya",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "1255364",
+ "name": "Surat",
+ "ascii": "Surat",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "2198148",
+ "name": "Suva",
+ "ascii": "Suva",
+ "country": "Fiji",
+ "timezone": "Pacific/Fiji"
+ },
+ {
+ "id": "1886760",
+ "name": "Suzhou",
+ "ascii": "Suzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2147714",
+ "name": "Sydney",
+ "ascii": "Sydney",
+ "country": "Australia",
+ "timezone": "Australia/Sydney"
+ },
+ {
+ "id": "1793724",
+ "name": "Tai’an",
+ "ascii": "Tai'an",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1668399",
+ "name": "Taichung",
+ "ascii": "Taichung",
+ "country": "Taiwan",
+ "timezone": "Asia/Taipei"
+ },
+ {
+ "id": "1668355",
+ "name": "Tainan",
+ "ascii": "Tainan",
+ "country": "Taiwan",
+ "timezone": "Asia/Taipei"
+ },
+ {
+ "id": "1821263",
+ "name": "Taipa",
+ "ascii": "Taipa",
+ "country": "Macao",
+ "timezone": "Asia/Macau"
+ },
+ {
+ "id": "1668341",
+ "name": "Taipei",
+ "ascii": "Taipei",
+ "country": "Taiwan",
+ "timezone": "Asia/Taipei"
+ },
+ {
+ "id": "1793511",
+ "name": "Taiyuan",
+ "ascii": "Taiyuan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1821940",
+ "name": "Takeo",
+ "ascii": "Takeo",
+ "country": "Cambodia",
+ "timezone": "Asia/Phnom_Penh"
+ },
+ {
+ "id": "588409",
+ "name": "Tallinn",
+ "ascii": "Tallinn",
+ "country": "Estonia",
+ "timezone": "Europe/Tallinn"
+ },
+ {
+ "id": "13527002",
+ "name": "Tandai",
+ "ascii": "Tandai",
+ "country": "Solomon Islands",
+ "timezone": "Pacific/Guadalcanal"
+ },
+ {
+ "id": "1625084",
+ "name": "Tangerang",
+ "ascii": "Tangerang",
+ "country": "Indonesia",
+ "timezone": "Asia/Jakarta"
+ },
+ {
+ "id": "1793346",
+ "name": "Tangshan",
+ "ascii": "Tangshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2110257",
+ "name": "Tarawa",
+ "ascii": "Tarawa",
+ "country": "Kiribati",
+ "timezone": "Pacific/Tarawa"
+ },
+ {
+ "id": "588335",
+ "name": "Tartu",
+ "ascii": "Tartu",
+ "country": "Estonia",
+ "timezone": "Europe/Tallinn"
+ },
+ {
+ "id": "1512569",
+ "name": "Tashkent",
+ "ascii": "Tashkent",
+ "country": "Uzbekistan",
+ "timezone": "Asia/Tashkent"
+ },
+ {
+ "id": "611717",
+ "name": "Tbilisi",
+ "ascii": "Tbilisi",
+ "country": "Georgia",
+ "timezone": "Asia/Tbilisi"
+ },
+ {
+ "id": "3600949",
+ "name": "Tegucigalpa",
+ "ascii": "Tegucigalpa",
+ "country": "Honduras",
+ "timezone": "America/Tegucigalpa"
+ },
+ {
+ "id": "112931",
+ "name": "Tehran",
+ "ascii": "Tehran",
+ "country": "Iran",
+ "timezone": "Asia/Tehran"
+ },
+ {
+ "id": "293397",
+ "name": "Tel Aviv",
+ "ascii": "Tel Aviv",
+ "country": "Israel",
+ "timezone": "Asia/Jerusalem"
+ },
+ {
+ "id": "1254661",
+ "name": "Thāne",
+ "ascii": "Thane",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "3573374",
+ "name": "The Valley",
+ "ascii": "The Valley",
+ "country": "Anguilla",
+ "timezone": "America/Anguilla"
+ },
+ {
+ "id": "734077",
+ "name": "Thessaloníki",
+ "ascii": "Thessaloniki",
+ "country": "Greece",
+ "timezone": "Europe/Athens"
+ },
+ {
+ "id": "1252416",
+ "name": "Thimphu",
+ "ascii": "Thimphu",
+ "country": "Bhutan",
+ "timezone": "Asia/Thimphu"
+ },
+ {
+ "id": "1792947",
+ "name": "Tianjin",
+ "ascii": "Tianjin",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3981609",
+ "name": "Tijuana",
+ "ascii": "Tijuana",
+ "country": "Mexico",
+ "timezone": "America/Tijuana"
+ },
+ {
+ "id": "3183875",
+ "name": "Tirana",
+ "ascii": "Tirana",
+ "country": "Albania",
+ "timezone": "Europe/Tirane"
+ },
+ {
+ "id": "617239",
+ "name": "Tiraspol",
+ "ascii": "Tiraspol",
+ "country": "Moldova",
+ "timezone": "Europe/Chisinau"
+ },
+ {
+ "id": "1053384",
+ "name": "Toamasina",
+ "ascii": "Toamasina",
+ "country": "Madagascar",
+ "timezone": "Indian/Antananarivo"
+ },
+ {
+ "id": "1850147",
+ "name": "Tokyo",
+ "ascii": "Tokyo",
+ "country": "Japan",
+ "timezone": "Asia/Tokyo"
+ },
+ {
+ "id": "6167865",
+ "name": "Toronto",
+ "ascii": "Toronto",
+ "country": "Canada",
+ "timezone": "America/Toronto"
+ },
+ {
+ "id": "2611396",
+ "name": "Tórshavn",
+ "ascii": "Torshavn",
+ "country": "Faroe Islands",
+ "timezone": "Atlantic/Faroe"
+ },
+ {
+ "id": "266826",
+ "name": "Tripoli",
+ "ascii": "Tripoli",
+ "country": "Lebanon",
+ "timezone": "Asia/Beirut"
+ },
+ {
+ "id": "2210247",
+ "name": "Tripoli",
+ "ascii": "Tripoli",
+ "country": "Libya",
+ "timezone": "Africa/Tripoli"
+ },
+ {
+ "id": "1219649",
+ "name": "Türkmenabat",
+ "ascii": "Tuerkmenabat",
+ "country": "Turkmenistan",
+ "timezone": "Asia/Ashgabat"
+ },
+ {
+ "id": "2464470",
+ "name": "Tunis",
+ "ascii": "Tunis",
+ "country": "Tunisia",
+ "timezone": "Africa/Tunis"
+ },
+ {
+ "id": "1529102",
+ "name": "Ürümqi",
+ "ascii": "UEruemqi",
+ "country": "China",
+ "timezone": "Asia/Urumqi"
+ },
+ {
+ "id": "2028462",
+ "name": "Ulan Bator",
+ "ascii": "Ulan Bator",
+ "country": "Mongolia",
+ "timezone": "Asia/Ulaanbaatar"
+ },
+ {
+ "id": "1880159",
+ "name": "Ulu Bedok",
+ "ascii": "Ulu Bedok",
+ "country": "Singapore",
+ "timezone": "Asia/Singapore"
+ },
+ {
+ "id": "933945",
+ "name": "Vacoas",
+ "ascii": "Vacoas",
+ "country": "Mauritius",
+ "timezone": "Indian/Mauritius"
+ },
+ {
+ "id": "1253573",
+ "name": "Vadodara",
+ "ascii": "Vadodara",
+ "country": "India",
+ "timezone": "Asia/Kolkata"
+ },
+ {
+ "id": "3042030",
+ "name": "Vaduz",
+ "ascii": "Vaduz",
+ "country": "Liechtenstein",
+ "timezone": "Europe/Vaduz"
+ },
+ {
+ "id": "6691831",
+ "name": "Vatican City",
+ "ascii": "Vatican City",
+ "country": "Vatican",
+ "timezone": "Europe/Vatican"
+ },
+ {
+ "id": "1931681",
+ "name": "Victoria",
+ "ascii": "Victoria",
+ "country": "Hong Kong",
+ "timezone": "Asia/Hong_Kong"
+ },
+ {
+ "id": "241131",
+ "name": "Victoria",
+ "ascii": "Victoria",
+ "country": "Seychelles",
+ "timezone": "Indian/Mahe"
+ },
+ {
+ "id": "2761369",
+ "name": "Vienna",
+ "ascii": "Vienna",
+ "country": "Austria",
+ "timezone": "Europe/Vienna"
+ },
+ {
+ "id": "1651944",
+ "name": "Vientiane",
+ "ascii": "Vientiane",
+ "country": "Laos",
+ "timezone": "Asia/Vientiane"
+ },
+ {
+ "id": "3587902",
+ "name": "Villa Nueva",
+ "ascii": "Villa Nueva",
+ "country": "Guatemala",
+ "timezone": "America/Guatemala"
+ },
+ {
+ "id": "593116",
+ "name": "Vilnius",
+ "ascii": "Vilnius",
+ "country": "Lithuania",
+ "timezone": "Europe/Vilnius"
+ },
+ {
+ "id": "756135",
+ "name": "Warsaw",
+ "ascii": "Warsaw",
+ "country": "Poland",
+ "timezone": "Europe/Warsaw"
+ },
+ {
+ "id": "1791681",
+ "name": "Weifang",
+ "ascii": "Weifang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1791388",
+ "name": "Wenzhou",
+ "ascii": "Wenzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3580477",
+ "name": "West Bay",
+ "ascii": "West Bay",
+ "country": "Cayman Islands",
+ "timezone": "America/Cayman"
+ },
+ {
+ "id": "7304591",
+ "name": "West Island",
+ "ascii": "West Island",
+ "country": "Cocos Islands",
+ "timezone": "Indian/Cocos"
+ },
+ {
+ "id": "3513090",
+ "name": "Willemstad",
+ "ascii": "Willemstad",
+ "country": "Curacao",
+ "timezone": "America/Curacao"
+ },
+ {
+ "id": "3352136",
+ "name": "Windhoek",
+ "ascii": "Windhoek",
+ "country": "Namibia",
+ "timezone": "Africa/Windhoek"
+ },
+ {
+ "id": "363807",
+ "name": "Winejok",
+ "ascii": "Winejok",
+ "country": "South Sudan",
+ "timezone": "Africa/Juba"
+ },
+ {
+ "id": "1791247",
+ "name": "Wuhan",
+ "ascii": "Wuhan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1790923",
+ "name": "Wuxi",
+ "ascii": "Wuxi",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1790842",
+ "name": "Wuzhong",
+ "ascii": "Wuzhong",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1790630",
+ "name": "Xi’an",
+ "ascii": "Xi'an",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1790645",
+ "name": "Xiamen",
+ "ascii": "Xiamen",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1788852",
+ "name": "Xining",
+ "ascii": "Xining",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1298824",
+ "name": "Yangon",
+ "ascii": "Yangon",
+ "country": "Myanmar",
+ "timezone": "Asia/Yangon"
+ },
+ {
+ "id": "1787227",
+ "name": "Yangzhou",
+ "ascii": "Yangzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1787093",
+ "name": "Yantai",
+ "ascii": "Yantai",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2220957",
+ "name": "Yaoundé",
+ "ascii": "Yaounde",
+ "country": "Cameroon",
+ "timezone": "Africa/Douala"
+ },
+ {
+ "id": "7626461",
+ "name": "Yaren",
+ "ascii": "Yaren",
+ "country": "Nauru",
+ "timezone": "Pacific/Nauru"
+ },
+ {
+ "id": "616052",
+ "name": "Yerevan",
+ "ascii": "Yerevan",
+ "country": "Armenia",
+ "timezone": "Asia/Yerevan"
+ },
+ {
+ "id": "4038794",
+ "name": "Yigo Village",
+ "ascii": "Yigo Village",
+ "country": "Guam",
+ "timezone": "Pacific/Guam"
+ },
+ {
+ "id": "1786657",
+ "name": "Yinchuan",
+ "ascii": "Yinchuan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1848354",
+ "name": "Yokohama",
+ "ascii": "Yokohama",
+ "country": "Japan",
+ "timezone": "Asia/Tokyo"
+ },
+ {
+ "id": "1785725",
+ "name": "Yunfu",
+ "ascii": "Yunfu",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "3186886",
+ "name": "Zagreb",
+ "ascii": "Zagreb",
+ "country": "Croatia",
+ "timezone": "Europe/Zagreb"
+ },
+ {
+ "id": "1785453",
+ "name": "Zaozhuang",
+ "ascii": "Zaozhuang",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "250090",
+ "name": "Zarqa",
+ "ascii": "Zarqa",
+ "country": "Jordan",
+ "timezone": "Asia/Amman"
+ },
+ {
+ "id": "1784658",
+ "name": "Zhengzhou",
+ "ascii": "Zhengzhou",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "6986104",
+ "name": "Zhongshan",
+ "ascii": "Zhongshan",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1790437",
+ "name": "Zhuhai",
+ "ascii": "Zhuhai",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "1785286",
+ "name": "Zibo",
+ "ascii": "Zibo",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ },
+ {
+ "id": "2657896",
+ "name": "Zürich",
+ "ascii": "Zuerich",
+ "country": "Switzerland",
+ "timezone": "Europe/Zurich"
+ },
+ {
+ "id": "1783621",
+ "name": "Zunyi",
+ "ascii": "Zunyi",
+ "country": "China",
+ "timezone": "Asia/Shanghai"
+ }
+]
diff --git a/examples/index.php b/examples/index.php
new file mode 100644
index 0000000..cadca18
--- /dev/null
+++ b/examples/index.php
@@ -0,0 +1,30 @@
+ 'text/css; charset=utf-8',
+ 'media.js' => 'text/javascript; charset=utf-8',
+ 'flux.js' => 'text/javascript; charset=utf-8',
+ 'palette.svg' => 'image/svg+xml',
+ 'palette.webm' => 'video/webm',
+];
+if(isset($_GET['asset'])) {
+ $asset = is_string($_GET['asset']) ? $_GET['asset'] : '';
+ if(!isset($assets[$asset])) {
+ http_response_code(404);
+ exit('Asset not found.');
+ }
+ header('Content-Type: ' . $assets[$asset]);
+ header('X-Content-Type-Options: nosniff');
+ readfile(__DIR__ . '/assets/' . $asset);
+ exit;
+}
+require __DIR__ . '/site.php';
+$page = input($_GET, 'page', 'home');
+if(!isset($pages[$page])) {
+ http_response_code(404);
+ $page = 'not-found';
+}
+handleSubmission();
+require __DIR__ . '/_header.html';
+require __DIR__ . '/pages/' . $page . '.php';
+require __DIR__ . '/_footer.html';
diff --git a/examples/package-lock.json b/examples/package-lock.json
new file mode 100644
index 0000000..4f9118d
--- /dev/null
+++ b/examples/package-lock.json
@@ -0,0 +1,441 @@
+{
+ "name": "flux-website",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "flux-website",
+ "dependencies": {
+ "flair": "file:../../../BrightFlair/flair"
+ },
+ "devDependencies": {
+ "sass": "^1.104.0"
+ }
+ },
+ "../../../BrightFlair/flair": {
+ "version": "0.1.0",
+ "devDependencies": {
+ "esbuild": "^0.28.2",
+ "sass": "^1.104.0"
+ }
+ },
+ "node_modules/@parcel/watcher": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
+ "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.6.0",
+ "@parcel/watcher-darwin-arm64": "2.6.0",
+ "@parcel/watcher-darwin-x64": "2.6.0",
+ "@parcel/watcher-freebsd-x64": "2.6.0",
+ "@parcel/watcher-linux-arm-glibc": "2.6.0",
+ "@parcel/watcher-linux-arm-musl": "2.6.0",
+ "@parcel/watcher-linux-arm64-glibc": "2.6.0",
+ "@parcel/watcher-linux-arm64-musl": "2.6.0",
+ "@parcel/watcher-linux-x64-glibc": "2.6.0",
+ "@parcel/watcher-linux-x64-musl": "2.6.0",
+ "@parcel/watcher-win32-arm64": "2.6.0",
+ "@parcel/watcher-win32-x64": "2.6.0"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz",
+ "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz",
+ "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz",
+ "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz",
+ "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz",
+ "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz",
+ "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz",
+ "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz",
+ "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz",
+ "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz",
+ "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz",
+ "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz",
+ "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/flair": {
+ "resolved": "../../../BrightFlair/flair",
+ "link": true
+ },
+ "node_modules/immutable": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
+ "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
+ "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/sass": {
+ "version": "1.104.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.104.0.tgz",
+ "integrity": "sha512-btHMApW2bgolvClhRW8AlQJzgI9lUB3pPSofBQQT+E46GWvf9o0TVQ13SYv5riWZVFyPN+JNz3TKW9XhBlc10w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^5.0.0",
+ "immutable": "^5.1.5",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ }
+ }
+}
diff --git a/examples/package.json b/examples/package.json
new file mode 100644
index 0000000..bbd7e19
--- /dev/null
+++ b/examples/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "flux-website",
+ "private": true,
+ "scripts": {
+ "build": "sass --load-path=node_modules/flair/style style/site.scss assets/site.css --no-source-map && cp ../dist/flux.js assets/flux.js",
+ "watch": "sass --load-path=node_modules/flair/style --watch style/site.scss:assets/site.css --no-source-map"
+ },
+ "dependencies": {
+ "flair": "file:../../../BrightFlair/flair"
+ },
+ "devDependencies": {
+ "sass": "^1.104.0"
+ }
+}
diff --git a/examples/pages/controls.php b/examples/pages/controls.php
new file mode 100644
index 0000000..e5dbe80
--- /dev/null
+++ b/examples/pages/controls.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/forms.php b/examples/pages/forms.php
new file mode 100644
index 0000000..68f2056
--- /dev/null
+++ b/examples/pages/forms.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/geometry.php b/examples/pages/geometry.php
new file mode 100644
index 0000000..29adde9
--- /dev/null
+++ b/examples/pages/geometry.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/home.php b/examples/pages/home.php
new file mode 100644
index 0000000..9fdecdc
--- /dev/null
+++ b/examples/pages/home.php
@@ -0,0 +1,7 @@
+Flux is a progressive enhancement library that enhances server-side updates, and exposes client-side state to CSS. The library is built as part of the PHP.GT project, intended to provide progressive enhancement by default in WebEngine projects. It can also be used independently, as these examples demonstrate.
+This website uses plain HTML and a small amount of PHP, without a framework, to keep the example code straightforward to read.
+The layout is an unthemed Flair layout, to make the plain HTML look a little more presentable, but without any distracting visuals.
+Each example below is achieved by adding data-flux attributes to HTML elements using standard HTTP forms to POST their data to PHP or use CSS variables to present the data, where appropriate. No JavaScript was written for these examples to work.
+
+
+
diff --git a/examples/pages/media.php b/examples/pages/media.php
new file mode 100644
index 0000000..3b3c2c3
--- /dev/null
+++ b/examples/pages/media.php
@@ -0,0 +1,3 @@
+
+View the JavaScript for image selection and video source fallback .
+
diff --git a/examples/pages/navigation.php b/examples/pages/navigation.php
new file mode 100644
index 0000000..f22b552
--- /dev/null
+++ b/examples/pages/navigation.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/not-found.php b/examples/pages/not-found.php
new file mode 100644
index 0000000..75cabe6
--- /dev/null
+++ b/examples/pages/not-found.php
@@ -0,0 +1 @@
+Return to the examples .
diff --git a/examples/pages/reference.php b/examples/pages/reference.php
new file mode 100644
index 0000000..32ba57e
--- /dev/null
+++ b/examples/pages/reference.php
@@ -0,0 +1,39 @@
+
+ Directives and examples The table links each public directive to an example. Combine directives with spaces; use parentheses to connect values to other elements.
+
+ Matching responses Give update targets stable IDs when you can. Flux can also match by DOM position, so responses should keep a consistent structure. The server returns complete HTML documents.
+ Basic setup <script type="module" src="/flux.js"></script>
+<form method="post" data-flux>
+ <button name="do" value="save">Save</button>
+</form>
+ Read the full attribute reference for defaults and lifecycle details.
+
diff --git a/examples/pages/scroll.php b/examples/pages/scroll.php
new file mode 100644
index 0000000..d860e52
--- /dev/null
+++ b/examples/pages/scroll.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/search.php b/examples/pages/search.php
new file mode 100644
index 0000000..506264e
--- /dev/null
+++ b/examples/pages/search.php
@@ -0,0 +1 @@
+
diff --git a/examples/pages/time.php b/examples/pages/time.php
new file mode 100644
index 0000000..a561876
--- /dev/null
+++ b/examples/pages/time.php
@@ -0,0 +1 @@
+
diff --git a/examples/site.php b/examples/site.php
new file mode 100644
index 0000000..99531e8
--- /dev/null
+++ b/examples/site.php
@@ -0,0 +1,162 @@
+ ['FLUX: Fluid User Experience', 'A progressive enhancement library'],
+ 'forms' => ['Forms and lists', 'Submit, save and reorder with ordinary server-rendered forms.'],
+ 'navigation' => ['Navigation and updates', 'Update selected elements from a full HTML response.'],
+ 'search' => ['Search', 'Preview results as you type, then follow a normal GET request.'],
+ 'time' => ['Time and date', 'Display local time, dates and calendar progress using CSS properties.'],
+ 'geometry' => ['Pointer and geometry', 'Read pointer coordinates, element dimensions and viewport visibility in CSS.'],
+ 'scroll' => ['Scroll position', 'Measure scroll offsets and an element’s passage through its scroll container.'],
+ 'controls' => ['Controls and validation', 'Read native input values and validation state in CSS.'],
+ 'media' => ['Media palettes', 'Use colours sampled from images and video frames in CSS.'],
+ 'reference' => ['Feature index', 'A working example for every public directive and attribute.'],
+];
+
+function h(string|int $value): string {
+ return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+function input(array $values, string $name, string $default = ''): string {
+ return isset($values[$name]) && is_string($values[$name]) ? $values[$name] : $default;
+}
+
+function pageUrl(string $name): string {
+ return '?page=' . rawurlencode($name);
+}
+
+function demo(string $name): void {
+ // Included templates use the current page and page catalogue.
+ global $page, $pages;
+ $templatePath = __DIR__ . '/components/' . $name . '.php';
+ require $templatePath;
+ ?>
+ View the HTML and PHP
+
This is the template used for the example above. Read the compiled CSS , or find its Sass in examples/style.
+
= h(file_get_contents($templatePath)) ?>
+
+
The shared list template = h(file_get_contents(__DIR__ . '/components/list.php')) ?>
+
+
+
The server actions = h(file_get_contents(__FILE__)) ?>
+
+
+
+
+
+ [
+ ['id' => 'plan', 'text' => 'Write the HTML', 'done' => false],
+ ['id' => 'try', 'text' => 'Add Flux attributes', 'done' => false],
+ ['id' => 'share', 'text' => 'Test the form', 'done' => false],
+ ],
+ 'shopping' => [
+ ['id' => 'bread', 'text' => 'Bread', 'done' => false],
+ ['id' => 'apples', 'text' => 'Apples', 'done' => false],
+ ['id' => 'coffee', 'text' => 'Coffee', 'done' => false],
+ ['id' => 'oats', 'text' => 'Oats', 'done' => false],
+ ],
+ ];
+ $_SESSION['board'] ??= ['ready' => [['id' => 'build', 'text' => 'Build an example'], ['id' => 'test', 'text' => 'Test the example']], 'finished' => []];
+ $_SESSION['board']['doing'] ??= [];
+ foreach(['horizontal', 'vertical'] as $direction) {
+ $_SESSION['lists']['order-' . $direction] ??= [
+ ['id' => 'first', 'text' => 'First'],
+ ['id' => 'second', 'text' => 'Second'],
+ ['id' => 'third', 'text' => 'Third'],
+ ];
+ }
+ if($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ session_write_close();
+ return;
+ }
+ if(!hash_equals($_SESSION['token'], input($_POST, 'token'))) {
+ http_response_code(403);
+ exit('This form has expired. Reload the page and try again.');
+ }
+ $demo = input($_POST, 'demo');
+ $action = input($_POST, 'do');
+ if(isset($_SESSION['lists'][$demo])) {
+ updateList($_SESSION['lists'][$demo], $action);
+ }
+ elseif($demo === 'board') {
+ if($action === 'add') addCard();
+ elseif($action === 'move') moveCard();
+ }
+ elseif($demo === 'single-counter' && in_array($action, ['minus', 'plus'], true)) {
+ $_SESSION['single-counter'] = ($_SESSION['single-counter'] ?? 0) + ($action === 'minus' ? -1 : 1);
+ }
+ elseif($demo === 'counter') {
+ $key = input($_POST, 'counter', 'a') === 'b' ? 'b' : 'a';
+ $_SESSION['counters'][$key] = ($_SESSION['counters'][$key] ?? 0) + ($action === 'minus' ? -1 : 1);
+ }
+ elseif($demo === 'note') {
+ $_SESSION['note'] = substr(input($_POST, 'note'), 0, 1000);
+ }
+ elseif($demo === 'attributes') {
+ $_SESSION['emphasis'] = !($_SESSION['emphasis'] ?? false);
+ }
+ elseif($demo === 'preferences') {
+ $_SESSION['display-name'] = substr(input($_POST, 'display-name'), 0, 24);
+ }
+ session_write_close();
+ global $page;
+ header('Location: ' . pageUrl($page), true, 303);
+ exit;
+}
+
+function updateList(array &$items, string $action): void {
+ $text = trim(input($_POST, 'item'));
+ if($action === 'add' && $text !== '' && count($items) < 50) {
+ $items[] = ['id' => bin2hex(random_bytes(6)), 'text' => substr($text, 0, 160), 'done' => false];
+ return;
+ }
+ $id = input($_POST, 'id');
+ foreach($items as $index => $item) {
+ if($item['id'] !== $id) continue;
+ if($action === 'toggle') $items[$index]['done'] = !$item['done'];
+ if($action === 'delete') array_splice($items, $index, 1);
+ if($action === 'move') {
+ $position = filter_var(input($_POST, 'order'), FILTER_VALIDATE_INT);
+ if($position === false) return;
+ array_splice($items, $index, 1);
+ array_splice($items, max(0, min(count($items), $position)), 0, [$item]);
+ }
+ return;
+ }
+}
+
+function addCard(): void {
+ $text = trim(input($_POST, 'item'));
+ if($text === '') return;
+ $_SESSION['board']['ready'][] = [
+ 'id' => bin2hex(random_bytes(6)),
+ 'text' => substr($text, 0, 160),
+ ];
+}
+
+function moveCard(): void {
+ $destination = input($_POST, 'parent');
+ $position = filter_var(input($_POST, 'order'), FILTER_VALIDATE_INT);
+ if(!isset($_SESSION['board'][$destination]) || $position === false) return;
+ foreach($_SESSION['board'] as $column => $items) {
+ foreach($items as $index => $item) {
+ if($item['id'] !== input($_POST, 'id')) continue;
+ array_splice($_SESSION['board'][$column], $index, 1);
+ $target = &$_SESSION['board'][$destination];
+ array_splice($target, max(0, min(count($target), $position)), 0, [$item]);
+ return;
+ }
+ }
+}
diff --git a/examples/style/_clock.scss b/examples/style/_clock.scss
new file mode 100644
index 0000000..2edf73f
--- /dev/null
+++ b/examples/style/_clock.scss
@@ -0,0 +1,98 @@
+@property --clock-hand-sin { syntax: ''; inherits: false; initial-value: 0; }
+@property --clock-hand-cos { syntax: ''; inherits: false; initial-value: 1; }
+
+.clock-dial {
+ --clock-tilt-x: clamp(-15deg, calc((.5 - var(--flux-pointer-y, .5)) * 60deg), 15deg);
+ --clock-tilt-y: clamp(-15deg, calc((var(--flux-pointer-x, .5) - .5) * 60deg), 15deg);
+ position: relative;
+ width: min(16rem, 100%);
+ aspect-ratio: 1;
+ margin: 1.5rem auto 2rem;
+ perspective: 700px;
+}
+.clock-dial::before {
+ /* Follow the face at one quarter of its tilt. */
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: rgb(0 0 0 / .16);
+ transform: translateY(1rem) scale(.97)
+ rotateX(calc(var(--clock-tilt-x) * .25))
+ rotateY(calc(var(--clock-tilt-y) * .25));
+ transition: transform 150ms ease-out;
+ filter: blur(.15rem);
+ pointer-events: none;
+}
+.clock-face {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1;
+ border: 0.2rem solid #111;
+ border-radius: 50%;
+ background: #fff;
+}
+.clock-face > span { position: absolute; }
+.clock-twelve { top: 0.3rem; left: 50%; transform: translateX(-50%); }
+.clock-three { right: 0.5rem; top: 50%; transform: translateY(-50%); }
+.clock-six { bottom: 0.3rem; left: 50%; transform: translateX(-50%); }
+.clock-nine { left: 0.5rem; top: 50%; transform: translateY(-50%); }
+.clock-hand {
+ bottom: 50%;
+ left: calc(50% - 0.15rem);
+ width: 0.3rem;
+ transform-origin: center bottom;
+ border-radius: 0.15rem;
+ /* Interpolate the direction components, not an angle that wraps at six or twelve. */
+ --clock-hand-sin: sin(var(--clock-hand-angle));
+ --clock-hand-cos: cos(var(--clock-hand-angle));
+ transform: rotate(atan2(var(--clock-hand-sin), var(--clock-hand-cos)));
+ transition:
+ --clock-hand-sin 180ms cubic-bezier(0.2, 0.8, 0.35, 1.15),
+ --clock-hand-cos 180ms cubic-bezier(0.2, 0.8, 0.35, 1.15);
+}
+.clock-hour { height: 25%; background: #111; --clock-hand-angle: calc(var(--flux-time-hour-scalar, 0) * 360deg); }
+.clock-minute { height: 35%; background: #444; --clock-hand-angle: calc(var(--flux-time-minute-scalar, 0) * 360deg); }
+.clock-second { height: 40%; width: 0.1rem; left: calc(50% - 0.05rem); background: #777; --clock-hand-angle: calc(var(--flux-time-second-scalar, 0) * 360deg); }
+.clock-centre { left: calc(50% - 0.3rem); top: calc(50% - 0.3rem); width: 0.6rem; height: 0.6rem; border-radius: 50%; background: #111; }
+.clock-digital { text-align: center; font-variant-numeric: tabular-nums; }
+.clock-digital::after {
+ counter-reset: hours max(1, calc(var(--flux-time-hour, 0) + 12 * (1 - min(1, var(--flux-time-hour, 0))))) minutes var(--flux-time-minute, 0) seconds var(--flux-time-second, 0);
+ content: counter(hours, decimal-leading-zero) ":" counter(minutes, decimal-leading-zero) ":" counter(seconds, decimal-leading-zero);
+}
+.calendar-date::after {
+ counter-reset: day var(--flux-date-day, 1) year var(--flux-date-year, 2026);
+ content: var(--flux-date-day-name, "") ", " counter(day) " " var(--flux-date-month-name, "") " " counter(year);
+}
+.calendar-short::after { content: var(--flux-date-day-name-short, "") " · " var(--flux-date-month-name-short, ""); }
+.calendar-progress { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 0.75rem; }
+.time-meter { display: block; height: 0.7rem; background: #eee; border-radius: 0.3rem; overflow: hidden; }
+.time-meter > span { display: block; height: 100%; background: #333; transform-origin: left; }
+.year-progress { transform: scaleX(var(--flux-date-year-scalar, 0)); }
+.month-progress { transform: scaleX(var(--flux-date-month-scalar, 0)); }
+.week-progress { transform: scaleX(var(--flux-date-week-scalar, 0)); }
+.day-progress { transform: scaleX(var(--flux-day-scalar, 0)); }
+
+@media (prefers-reduced-motion: reduce) {
+ .clock-hand { transition: none; }
+}
+
+.clock-stage { text-align: center; }
+.clock-face {
+ /* Limit the tilt to 15 degrees per axis. */
+ transform:
+ rotateX(var(--clock-tilt-x))
+ rotateY(var(--clock-tilt-y));
+ transition: transform 150ms ease-out;
+}
+.clock-stage:not(:hover) .clock-dial { --clock-tilt-x: 0deg; --clock-tilt-y: 0deg; }
+.clock-digital { font-size: 1.5rem; }
+.calendar-date, .calendar-short { font-size: .9rem; }
+.calendar-progress { margin-block: 2rem; }
+.calendar-progress dd { margin: 0; }
+.calendar-progress .bar { min-width: 4rem; }
+@media (prefers-reduced-motion: reduce) {
+ .clock-dial { --clock-tilt-x: 0deg; --clock-tilt-y: 0deg; }
+ .clock-face { transform: none; transition: none; }
+ .clock-dial::before { transition: none; }
+}
diff --git a/examples/style/_controls.scss b/examples/style/_controls.scss
new file mode 100644
index 0000000..d1946b4
--- /dev/null
+++ b/examples/style/_controls.scss
@@ -0,0 +1,49 @@
+@use "flair";
+
+@property --redline { syntax: ''; inherits: true; initial-value: 0; }
+.gauge-stage {
+ --load: var(--flux-range, .35);
+ --warm: clamp(0, (var(--load) - .55) * 5, 1);
+ --hot: clamp(0, (var(--load) - .8) * 10, 1);
+ --redline: clamp(0, (var(--load) - .9) * 1000, 1);
+ --gauge-colour: color-mix(in srgb, color-mix(in srgb, #555, #db7700 calc(var(--warm) * 100%)), #ce241c calc(var(--hot) * 100%));
+ text-align: center;
+}
+.radial-gauge { width: min(16rem, 100%); aspect-ratio: 1; margin-inline: auto; border-radius: 50%; padding: 1.1rem; background: conic-gradient(from 225deg, var(--gauge-colour) calc(var(--load) * 270deg), #e5e5e5 0deg 270deg, transparent 0); }
+.gauge-value { @extend %o-value; display: grid; place-items: center; height: 100%; border-radius: 50%; background: var(--flair-color-surface); }
+.gauge-value::after { counter-reset: load calc(var(--load) * 100); content: counter(load) '%'; }
+.gauge-status { display: grid; color: var(--gauge-colour); font-weight: 700; }
+.gauge-status span { grid-area: 1 / 1; }
+.gauge-status span:first-child { opacity: calc(1 - var(--redline)); }
+.gauge-status span:last-child { opacity: var(--redline); }
+@container style(--redline: 1) and style(--flux-visible: 1) { .radial-gauge { animation: strain 90ms linear infinite alternate; } }
+@keyframes strain { from { transform: translate(-1px, .5px) rotate(-.4deg); } to { transform: translate(1px, -.5px) rotate(.4deg); } }
+.level-stage { --level: calc(var(--flux-range, 0) * 4); width: min(18rem, 100%); margin-inline: auto; }
+.level-current { display: grid; text-align: center; font-size: 1.8rem; font-weight: 750; }
+.level-current span { grid-area: 1 / 1; opacity: calc(1 - min(1, max(var(--level) - var(--step), var(--step) - var(--level)))); color: color-mix(in srgb, #666, #cb291c calc(var(--step) * 25%)); }
+.level-control { display: grid; grid-template-columns: 3rem 1fr; gap: 2rem; justify-content: center; padding: 1rem; }
+.level-control label { display: flex; justify-content: center; }
+.level-control input[type='range'] { writing-mode: vertical-lr; direction: rtl; width: 2rem; height: 15rem; accent-color: color-mix(in srgb, #777, #ce241c calc(var(--flux-range, 0) * 100%)); }
+.level-labels { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; justify-content: space-between; font-weight: 700; }
+.level-labels li { margin: 0; --lit: clamp(0, var(--level) - var(--step) + 1, 1); --step-colour: color-mix(in srgb, #d08000, #ce241c calc(max(0, var(--step) - 2) * 50%)); color: color-mix(in srgb, #aaa, var(--step-colour) calc(var(--lit) * min(1, var(--step)) * 100%)); transition: color 150ms; }
+.bar { display: block; height: .65rem; background: var(--flair-color-surface-disabled); overflow: hidden; }
+.bar > span { display: block; height: 100%; background: var(--flair-color-accent); transform-origin: left; }
+.column-preview { display: grid; grid-template-columns: repeat(var(--flux-select, 2), 1fr); gap: .5rem; margin-block: 1rem; }
+.column-preview span { background: var(--flair-color-surface-disabled); padding: 1rem; }
+.colour-preview {
+ --preview-background: color-mix(in srgb, var(--flux-color, #555) 20%, white);
+ border-left: .6rem solid var(--flux-color, #555);
+ background: var(--preview-background);
+ color: rgb(from var(--preview-background) calc(255 - r) calc(255 - g) calc(255 - b));
+ padding: 1rem;
+ margin-top: 1rem;
+}
+.field-budget::after { counter-reset: remaining var(--flux-field-remaining, 24); content: counter(remaining) ' characters remaining'; }
+.field-state { @extend %p-cluster; --flair-cluster-gap: var(--flair-space-2); }
+.field-state span { @extend %o-badge; }
+.field-state span:nth-child(1) { opacity: calc(.25 + var(--flux-field-dirty, 0) * .75); }
+.field-state span:nth-child(2) { opacity: calc(.25 + var(--flux-field-touched, 0) * .75); }
+.field-state span:nth-child(3) { opacity: calc(.25 + var(--flux-field-changed, 0) * .75); }
+.validity-progress { transform: scaleX(var(--flux-form-valid-scalar, 0)); }
+.form-count::after { counter-reset: valid var(--flux-form-valid-count, 0) total var(--flux-form-field-count, 0); content: counter(valid) ' of ' counter(total) ' fields valid'; }
+@media (prefers-reduced-motion: reduce) { .radial-gauge { animation: none !important; } .level-labels li { transition: none; } }
diff --git a/examples/style/_geometry.scss b/examples/style/_geometry.scss
new file mode 100644
index 0000000..38d60f0
--- /dev/null
+++ b/examples/style/_geometry.scss
@@ -0,0 +1,21 @@
+.pointer-pad { position: relative; min-height: 12rem; padding: 1rem; border: 1px solid; overflow: hidden; }
+.pointer-marker { position: absolute; left: calc(var(--flux-pointer-x, .5) * 100%); top: calc(var(--flux-pointer-y, .5) * 100%); width: 1rem; height: 1rem; border-radius: 50%; background: #111; transform: translate(-50%, -50%); pointer-events: none; }
+.pointer-preview { margin-top: 1rem; width: min(14rem, 100%); min-height: 8rem; }
+.pointer-readout::after { counter-reset: x calc(var(--flux-pointer-x-px, 0)) y calc(var(--flux-pointer-y-px, 0)); content: counter(x) 'px, ' counter(y) 'px'; }
+.viewport-pointer-readout::after { counter-reset: x calc(var(--flux-pointer-global-x-px, 0)) y calc(var(--flux-pointer-global-y-px, 0)); content: counter(x) 'px, ' counter(y) 'px'; }
+.size-box { resize: both; overflow: auto; width: 23rem; max-width: 100%; min-width: 8rem; min-height: 7rem; padding: 1rem; border: 1px solid; }
+.size-readout::after { counter-reset: width calc(var(--flux-size-x, 0)) height calc(var(--flux-size-y, 0)); content: counter(width) ' × ' counter(height) ' CSS pixels'; }
+.truncated-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.truncation-note { opacity: var(--flux-truncated, 0); color: #ce241c; }
+.visibility-preview { margin-top: 2rem; padding: 1rem; border: 3px solid rgb(0 0 0 / var(--flux-visible, 1)); }
+.palette-preview { margin: 0; box-shadow: 0 1rem 3rem .5rem var(--flux-palette, #333); padding: .5rem; }
+.palette-preview img, .palette-preview video { display: block; width: 100%; height: auto; }
+.palette-preview figcaption { padding-block: 1rem; }
+.swatches { display: flex; height: 2rem; }
+.swatches span { flex: 1; }
+.swatches .accent { background: var(--flux-palette-accent, #777); }
+.swatches .dark { background: var(--flux-palette-dark, #222); }
+.swatches .light { background: var(--flux-palette-light, #ddd); }
+.swatches .average { background: var(--flux-palette-average, #888); }
+.temperature { position: relative; height: .5rem; margin-top: 1rem; background: linear-gradient(to right, #aaa, white, #555); }
+.temperature span { position: absolute; left: calc((var(--flux-palette-temp, 0) + 1) * 50%); height: 1rem; width: .25rem; top: -.25rem; background: #111; }
diff --git a/examples/style/_interactions.scss b/examples/style/_interactions.scss
new file mode 100644
index 0000000..4162288
--- /dev/null
+++ b/examples/style/_interactions.scss
@@ -0,0 +1,25 @@
+.kanban-grid { display: grid; grid-template-columns: repeat(3, minmax(15rem, 1fr)); gap: 1rem; overflow-x: auto; }
+.order-horizontal { display: flex; gap: 1rem; overflow-x: auto; padding-block: .5rem; }
+.order-list > li { border: 1px solid #ccc; padding: 1rem; }
+.order-horizontal > li { flex: 1 0 5rem; }
+.order-vertical { max-width: 28rem; }
+.order-vertical > li + li { margin-top: .5rem; }
+.arrow-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); width: 100%; }
+.arrow-cell { display: grid; place-items: center; aspect-ratio: 1; }
+@property --arrow-sin { syntax: ''; inherits: false; initial-value: 0; }
+@property --arrow-cos { syntax: ''; inherits: false; initial-value: 1; }
+.pointer-arrow {
+ display: block;
+ font-size: clamp(1rem, 3vw, 2rem);
+ line-height: 1;
+ --arrow-angle: atan2(calc(var(--flux-pointer-y-raw-px, 0) - var(--flux-size-y, 0) / 2), calc(var(--flux-pointer-x-raw-px, 1) - var(--flux-size-x, 0) / 2));
+ /* Smooth direction components so crossing ±180deg does not cause a full spin. */
+ --arrow-sin: sin(var(--arrow-angle));
+ --arrow-cos: cos(var(--arrow-angle));
+ transform: rotate(atan2(var(--arrow-sin), var(--arrow-cos)));
+ transition: --arrow-sin 0.25s ease-out, --arrow-cos 0.25s ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pointer-arrow { transition: none; }
+}
diff --git a/examples/style/_scroll.scss b/examples/style/_scroll.scss
new file mode 100644
index 0000000..1e160f2
--- /dev/null
+++ b/examples/style/_scroll.scss
@@ -0,0 +1,63 @@
+@use "flair";
+
+.scroll-offset-box, .scroll-passage-box {
+ @extend %o-inset;
+ height: 18rem;
+ overflow: auto;
+ overscroll-behavior: contain;
+}
+.scroll-reading { min-height: 45rem; display: flex; flex-direction: column; }
+.scroll-reading-middle { margin-block: auto; }
+.scroll-meter { margin-block: var(--flair-space-6); }
+.scroll-offset-readout, .scroll-passage-readout { @extend %o-value; --flair-value-size: 2rem; text-align: center; }
+.scroll-offset-readout::after {
+ counter-reset: percent calc(var(--flux-scroll-y, 0) * 100) pixels calc(var(--flux-scroll-y-px, 0));
+ content: counter(percent) '% · ' counter(pixels) 'px';
+}
+.scroll-offset-fill { transform: scaleX(var(--flux-scroll-y, 0)); }
+.scroll-passage-spacer { height: 24rem; margin: 0; }
+@property --scroll-passage-wobble { syntax: ''; inherits: true; initial-value: 0; }
+.scroll-passage-marker {
+ // Measure this stable box; transforms belong to its artwork child.
+ height: 8rem;
+ --scroll-passage-reveal: clamp(0, var(--flux-scroll-midway-y, 0) / .8, 1);
+ --scroll-passage-wobble: clamp(0, (var(--flux-scroll-midway-y, 0) - .75) * 1000, 1);
+}
+.scroll-passage-artwork {
+ @extend %o-inset;
+ height: 100%;
+ opacity: var(--scroll-passage-reveal);
+ scale: var(--scroll-passage-reveal);
+ display: grid;
+ align-content: center;
+ background: color-mix(in srgb, var(--flair-color-surface) calc((1 - clamp(0, var(--flux-scroll-progress-y, 0), 1)) * 100%), var(--flair-color-surface-disabled));
+ border-inline-start: var(--flair-space-2) solid var(--flair-color-accent);
+}
+.scroll-passage-readout::after {
+ counter-reset: progress calc(var(--flux-scroll-progress-y, 0) * 100);
+ content: counter(progress) '% through';
+}
+.scroll-passage-fill { transform: scaleX(clamp(0, var(--flux-scroll-progress-y, 0), 1)); }
+
+.scroll-passage-inverse-readout::after {
+ counter-reset: progress calc(var(--flux-scroll-progress-y-inverse, 1) * 100);
+ content: counter(progress) '% remaining';
+}
+.scroll-passage-inverse-fill { transform: scaleX(clamp(0, var(--flux-scroll-progress-y-inverse, 1), 1)); }
+
+.scroll-passage-midway-readout::after {
+ counter-reset: midway calc(var(--flux-scroll-midway-y, 0) * 100);
+ content: counter(midway) '% centred';
+}
+.scroll-passage-midway-fill { transform: scaleX(clamp(0, var(--flux-scroll-midway-y, 0), 1)); }
+
+@container style(--scroll-passage-wobble: 1) {
+ .scroll-passage-artwork { animation: scroll-passage-wobble 350ms ease-in-out infinite alternate; }
+}
+@keyframes scroll-passage-wobble {
+ from { rotate: -3deg; }
+ to { rotate: 3deg; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .scroll-passage-artwork { scale: none; animation: none; }
+}
diff --git a/examples/style/site.scss b/examples/style/site.scss
new file mode 100644
index 0000000..8ecc725
--- /dev/null
+++ b/examples/style/site.scss
@@ -0,0 +1,66 @@
+@use "flair";
+@use "theme";
+@use "clock";
+@use "controls";
+@use "geometry";
+@use "scroll";
+@use "interactions";
+
+:root { @include theme.base; --flair-font-body: system-ui, sans-serif; }
+@include flair.typography;
+@include flair.controls;
+@include flair.base;
+body { @extend %d-typography; background: var(--flair-color-surface); color: var(--flair-color-text); }
+.site-header, .site-footer, .site-layout { @extend %l-page-frame; }
+.site-header { @extend %p-page-header; }
+.wordmark { color: inherit; font-size: 2.8rem; font-weight: 850; letter-spacing: -.08em; text-decoration: none; display: flex; align-items: baseline; gap: 1rem; }
+.wordmark span { font-size: .7rem; letter-spacing: .1em; }
+.site-layout { @extend %l-sidebar; --flair-sidebar-width: 13rem; --flair-content-min: 22rem; --flair-layout-gap: 3rem; --flair-page-padding-block: 3rem; @include flair.sticky-sidebar; }
+.site-nav { @extend %p-side-navigation; }
+.sidebar-note { font-size: .8rem; color: var(--flair-color-muted); margin-block: 2rem; }
+.page-intro { @extend %p-page-intro; }
+.lead { @extend %d-typography-lead; }
+.eyebrow { @extend %d-typography-eyebrow; }
+.demo { @extend %o-card; @extend %d-state-reveal; --flair-reveal-progress: var(--flux-first-visible, 1); --flair-surface-padding: clamp(1.25rem, 3vw, 2.5rem); --flair-card-title-size: 1.8rem; margin-top: 3rem; scroll-margin-top: 1rem; }
+.split, .demo-grid { @extend %p-grid; --flair-grid-columns: 2; --flair-grid-min: 17rem; align-items: start; }
+.demo-copy p { max-width: 45ch; }
+.demo h2 { --flair-heading-2: 1.8rem; }
+.demo h3 { --flair-heading-3: 1.2rem; --flair-card-title-size: 1.2rem; }
+.source { @extend %o-disclosure; margin-top: .6rem; font-size: .85rem; }
+pre { @extend %o-code-block; max-height: 30rem; overflow: auto; }
+.fields { @extend %p-form-fields; }
+.field { @extend %o-form-field; }
+.actions { @extend %p-form-actions; }
+.chapter-nav { @extend %p-navigation; }
+.table-scroll { @extend %p-table-scroll; }
+.notice { @extend %o-notice; }
+// DOM reordering must not make the browser scroll to follow an anchored row.
+.sortable-list { overflow-anchor: none; @extend %p-action-list; --flair-list-padding: var(--flair-space-3); margin-block-end: var(--flair-space-6); }
+.complete .item-text { text-decoration: line-through; color: var(--flair-color-muted); }
+.item-actions { --flair-button-padding-inline: var(--flair-space-2); }
+.drag-handle { width: 1.25rem; height: 2rem; cursor: grab; touch-action: none; display: grid; place-items: center; }
+.drag-handle::before { content: '⠿'; font-size: 1.5rem; }
+.flux-drag-order-dragging { @extend %d-state-dragging; }
+.drag-form { margin: 0; }
+.list-demo .fields { margin-top: 1rem; }
+.big-number { @extend %o-value; }
+#single-counter, #counter-a, #counter-b { @extend %p-metric; }
+.counter-sum { @extend %p-output-row; --flair-value-size: 4rem; margin-block: var(--flair-space-8); }
+.search-results { @extend %p-search-results; --flair-list-padding: var(--flair-space-3); }
+.chapter { border-block: var(--flair-border-width) solid var(--flair-color-border); padding-block: var(--flair-space-4); margin-block: var(--flair-space-6); }
+#updates-demo .demo-grid > p { @extend %o-inset; }
+.emphasised { color: var(--flair-color-on-accent); background: var(--flair-color-accent); }
+.site-footer { @extend %p-page-footer; }
+.next-steps { padding-block: var(--flair-space-8); }
+.skip-link { @extend %o-skip-link; }
+.visually-hidden { @extend %d-visually-hidden; }
+.flux-form-waiting button { @extend %d-state-busy; }
+@media (min-width: 70rem) { #single-counter { --flair-metric-align: end; } }
+@media print { .site-nav, .source, .site-header > p { display: none; } .demo { opacity: 1; translate: none; break-inside: avoid; } }
+
+.board-column { min-height: 10rem; border: 1px dashed var(--flair-color-border); padding: .75rem; }
+
+.city-dialog { @extend %o-dialog; }
+.city-dialog dl { @extend %p-data-list; --flair-data-min: 100%; }
+.city-results { --flair-results-max-height: 22rem; }
+code { @extend %o-code; }
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..55ec949
--- /dev/null
+++ b/index.php
@@ -0,0 +1,5 @@
+ {},
) {
this.navigationController = navigationController;
this.logger = logger;
@@ -18,6 +19,7 @@ export class AutocompleteHandler {
this.scheduler = scheduler;
this.clearScheduler = clearScheduler;
this.delay = delay;
+ this.onResults = onResults;
this.state = new WeakMap();
}
@@ -30,11 +32,20 @@ export class AutocompleteHandler {
return;
}
+ let resultsElement = fluxElement.nextElementSibling;
+ if(!resultsElement?.matches('[data-flux~="autocomplete-results"]')) {
+ resultsElement = null;
+ }
+ if(resultsElement) {
+ resultsElement.dataset["fluxAutocompleteMounted"] = "";
+ resultsElement.addEventListener("keydown", this.onResultsKeyDown);
+ }
+
this.state.set(fluxElement, {
timer: null,
minLength: this.getMinLength(fluxElement),
requestId: 0,
- resultsElement: null,
+ resultsElement,
});
this.hideSubmitControls(fluxElement);
fluxElement.addEventListener("input", this.onInput);
@@ -138,7 +149,7 @@ export class AutocompleteHandler {
}
applyResults(form, state, newDocument) {
- let newResultsElement = newDocument.querySelector('[data-flux="autocomplete-results"]');
+ let newResultsElement = newDocument.querySelector('[data-flux~="autocomplete-results"]');
if(!newResultsElement) {
this.removeResults(form, state);
if(this.debug) {
@@ -157,6 +168,7 @@ export class AutocompleteHandler {
}
state.resultsElement = newResultsElement;
+ this.onResults(newResultsElement);
}
onResultsKeyDown = (e) => {
diff --git a/src/CssProperties/Binding.es6 b/src/CssProperties/Binding.es6
new file mode 100644
index 0000000..b23583a
--- /dev/null
+++ b/src/CssProperties/Binding.es6
@@ -0,0 +1,89 @@
+/** Connects one source to its local element and optional remote CSS destinations. */
+export class Binding {
+ constructor(runtime, element, name, definition, state = {}) {
+ this.runtime = runtime;
+ this.element = element;
+ this.name = name;
+ this.definition = definition;
+ this.state = state;
+ this.values = new Map();
+ this.destinations = new Map();
+ this.source = new definition.source(this);
+ this.disposed = false;
+ this.visible = false;
+ this.resizeEntry = null;
+ this.offVisibility = runtime.observeVisibility(element, visible => {
+ this.visible = visible;
+ this.requestRefresh();
+ });
+ this.offResize = definition.resize ? runtime.observeResize(element, entry => {
+ this.resizeEntry = entry;
+ this.requestRefresh();
+ }) : null;
+ this.requestRefresh();
+ }
+
+ get active() {
+ if(this.runtime.document.hidden) return false;
+ if(this.visible) return true;
+ for(let target of this.destinations.values()) if(target.visible) return true;
+ return false;
+ }
+
+ requestRefresh = () => {
+ if(!this.disposed) this.runtime.scheduler.measure(this.refresh);
+ }
+
+ refresh = () => {
+ if(this.disposed || !this.element.isConnected) return;
+ if(this.definition.always || this.active) this.source.refresh();
+ else this.source.stop?.();
+ }
+
+ set(suffix, value) {
+ if(typeof value === "number") {
+ if(!Number.isFinite(value)) value = 0;
+ value = Math.round(value * 10000) / 10000;
+ }
+ let name = this.definition.properties?.[suffix]
+ ?? `--${this.name}${suffix ? "-" + suffix : ""}`;
+ this.values.set(name, value);
+ this.runtime.writer.set(this, this.element, name, value);
+ for(let destination of this.destinations.keys()) this.runtime.writer.set(this, destination, name, value);
+ }
+
+ connect(elements) {
+ for(let [element, target] of this.destinations) {
+ if(elements.has(element)) continue;
+ target.dispose();
+ this.destinations.delete(element);
+ this.runtime.writer.release(this, element);
+ }
+ for(let element of elements) {
+ if(element === this.element || this.destinations.has(element)) continue;
+ let target = {visible: false, dispose: () => {}};
+ this.destinations.set(element, target);
+ target.dispose = this.runtime.observeVisibility(element, visible => {
+ target.visible = visible;
+ this.requestRefresh();
+ });
+ for(let [name, value] of this.values) this.runtime.writer.set(this, element, name, value);
+ }
+ }
+
+ clear() {
+ this.values.clear();
+ this.runtime.writer.release(this);
+ }
+
+ dispose() {
+ this.disposed = true;
+ this.runtime.scheduler.forget(this.refresh);
+ this.source.dispose();
+ this.offVisibility();
+ this.offResize?.();
+ for(let target of this.destinations.values()) target.dispose();
+ this.destinations.clear();
+ this.clear();
+ }
+}
diff --git a/src/CssProperties/CalendarValues.es6 b/src/CssProperties/CalendarValues.es6
new file mode 100644
index 0000000..45aabfe
--- /dev/null
+++ b/src/CssProperties/CalendarValues.es6
@@ -0,0 +1,47 @@
+const SECONDS_PER_DAY = 86400;
+const MILLISECONDS_PER_DAY = 86400000;
+
+/** Calendar arithmetic uses local date parts, so daylight-saving offsets do not change day counts. */
+export function calendarValues(date) {
+ let year = date.getFullYear();
+ let monthIndex = date.getMonth();
+ let day = date.getDate();
+ let weekday = (date.getDay() + 6) % 7 + 1;
+ let dayScalar = (date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds()) / SECONDS_PER_DAY;
+ let startOfYear = calendarDay(year, 0, 1);
+ let daysInYear = calendarDay(year + 1, 0, 1) - startOfYear;
+ let daysInMonth = calendarDay(year, monthIndex + 1, 1) - calendarDay(year, monthIndex, 1);
+ return {
+ year,
+ month: monthIndex + 1,
+ day,
+ weekday,
+ "year-scalar": (calendarDay(year, monthIndex, day) - startOfYear + dayScalar) / daysInYear,
+ "month-scalar": (day - 1 + dayScalar) / daysInMonth,
+ "week-scalar": (weekday - 1 + dayScalar) / 7,
+ "day-scalar": dayScalar,
+ };
+}
+
+function calendarDay(year, month, day) {
+ // setUTCFullYear also handles years 0–99 without Date.UTC's 1900 offset.
+ let date = new Date(0);
+ date.setUTCFullYear(year, month, day);
+ return date.getTime() / MILLISECONDS_PER_DAY;
+}
+
+export function timeValues(date) {
+ let second = date.getSeconds();
+ let minute = date.getMinutes();
+ let hour = date.getHours() % 12;
+ let secondScalar = second / 60;
+ let minuteScalar = (minute + secondScalar) / 60;
+ return {
+ second,
+ minute,
+ hour,
+ "second-scalar": secondScalar,
+ "minute-scalar": minuteScalar,
+ "hour-scalar": (hour + minuteScalar) / 12,
+ };
+}
diff --git a/src/CssProperties/Clock.es6 b/src/CssProperties/Clock.es6
new file mode 100644
index 0000000..fb9f3b1
--- /dev/null
+++ b/src/CssProperties/Clock.es6
@@ -0,0 +1,42 @@
+/** Shares one wall-clock timer between all visible time and date sources. */
+export class Clock {
+ constructor(windowObject = window) {
+ this.window = windowObject;
+ this.subscribers = new Set();
+ this.timer = null;
+ }
+
+ now() { return new this.window.Date(); }
+
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ this.schedule();
+ return () => {
+ this.subscribers.delete(callback);
+ if(!this.subscribers.size) this.cancel();
+ };
+ }
+
+ schedule() {
+ if(this.timer !== null || !this.subscribers.size) return;
+ // Recalculate from the clock each tick rather than accumulating interval drift.
+ let delay = 1000 - this.now().getMilliseconds();
+ this.timer = this.window.setTimeout(this.tick, delay);
+ }
+
+ tick = () => {
+ this.timer = null;
+ for(let callback of this.subscribers) callback();
+ this.schedule();
+ }
+
+ cancel() {
+ if(this.timer !== null) this.window.clearTimeout(this.timer);
+ this.timer = null;
+ }
+
+ dispose() {
+ this.cancel();
+ this.subscribers.clear();
+ }
+}
diff --git a/src/CssProperties/ConnectionResolver.es6 b/src/CssProperties/ConnectionResolver.es6
new file mode 100644
index 0000000..4f76283
--- /dev/null
+++ b/src/CssProperties/ConnectionResolver.es6
@@ -0,0 +1,42 @@
+import {DirectiveParser} from "../DirectiveParser.es6";
+import {CSS_SOURCES} from "./SourceRegistry.es6";
+
+/** Resolves connections once per DOM synchronisation, never during pointer or frame updates. */
+export class ConnectionResolver {
+ constructor(documentObject, logger) {
+ this.document = documentObject;
+ this.logger = logger;
+ this.declarations = new WeakMap();
+ this.selectors = new Map();
+ }
+
+ begin() { this.selectors.clear(); }
+
+ resolve(element) {
+ let value = element.dataset["flux"];
+ let cached = this.declarations.get(element);
+ if(!cached || cached.value !== value) {
+ cached = {value, tokens: DirectiveParser.parse(value)};
+ this.declarations.set(element, cached);
+ }
+ let sources = new Map();
+ for(let {names, selector} of cached.tokens) {
+ let destinations = selector ? this.find(selector) : [];
+ for(let name of names) {
+ if(!Object.hasOwn(CSS_SOURCES, name)) continue;
+ if(!sources.has(name)) sources.set(name, new Set());
+ for(let destination of destinations) sources.get(name).add(destination);
+ }
+ }
+ return sources;
+ }
+
+ find(selector) {
+ if(this.selectors.has(selector)) return this.selectors.get(selector);
+ let elements = [];
+ try { elements = this.document.querySelectorAll(selector); }
+ catch(error) { this.logger.error(`Invalid Flux connection selector: ${selector}`, error); }
+ this.selectors.set(selector, elements);
+ return elements;
+ }
+}
diff --git a/src/CssProperties/ControlSource.es6 b/src/CssProperties/ControlSource.es6
new file mode 100644
index 0000000..a13be13
--- /dev/null
+++ b/src/CssProperties/ControlSource.es6
@@ -0,0 +1,121 @@
+const SELECTORS = {
+ "flux-range": 'input[type="range"]',
+ "flux-select": "select",
+ "flux-color": 'input[type="color"]',
+ "flux-field": "input, textarea",
+ "flux-form": "form",
+};
+
+export function findControl(element, name) {
+ let selector = SELECTORS[name];
+ return element.matches(selector) ? element : element.querySelector(selector);
+}
+
+export function scalar(value) { return Math.max(0, Math.min(1, value)); }
+
+/** Reads native control values and validation state; interaction history belongs to the binding. */
+export class ControlSource {
+ constructor(binding) {
+ this.binding = binding;
+ this.resolveControl();
+ }
+
+ resolveControl() {
+ let {element, name, state, runtime} = this.binding;
+ this.control = findControl(element, name);
+ if(this.control && name === "flux-field" && state.initial === undefined) {
+ state.initial = this.valueOf(this.control);
+ state.touched = this.control === runtime.document.activeElement;
+ }
+ }
+
+ refresh() {
+ this.resolveControl();
+ let {name} = this.binding;
+ let control = this.control;
+ if(!control) { this.binding.clear(); return; }
+ switch(name) {
+ case "flux-range": this.range(control); break;
+ case "flux-select": this.select(control); break;
+ case "flux-color": this.binding.set("", control.value); break;
+ case "flux-field": this.field(control); break;
+ case "flux-form": this.form(control); break;
+ }
+ }
+
+ range(control) {
+ let minimum = control.min === "" ? 0 : Number(control.min);
+ let maximum = control.max === "" ? 100 : Number(control.max);
+ this.binding.set("", maximum > minimum ? scalar((control.valueAsNumber - minimum) / (maximum - minimum)) : 0);
+ }
+
+ select(control) {
+ let value = control.value.trim();
+ this.binding.set("", value && Number.isFinite(Number(value)) ? Number(value) : null);
+ }
+
+ field(control) {
+ let {state} = this.binding;
+ let length = control.value.length;
+ let maximum = control.maxLength;
+ let valid = control.validity.valid;
+ this.binding.set("length", length);
+ this.binding.set("empty", Number(length === 0));
+ this.binding.set("valid", Number(valid));
+ this.binding.set("invalid", Number(!valid));
+ this.binding.set("remaining", maximum >= 0 ? Math.max(0, maximum - length) : null);
+ this.binding.set("filled-scalar", maximum >= 0 ? (maximum ? scalar(length / maximum) : 0) : null);
+ this.binding.set("too-short", Number(control.validity.tooShort));
+ this.binding.set("pattern-error", Number(control.validity.patternMismatch));
+ this.pair("dirty", "clean", state.dirty);
+ this.pair("touched", "untouched", state.touched);
+ this.pair("changed", "unchanged", this.valueOf(control) !== state.initial);
+ }
+
+ pair(positive, negative, value) {
+ this.binding.set(positive, Number(Boolean(value)));
+ this.binding.set(negative, Number(!value));
+ }
+
+ form(control) {
+ let fields = [...control.elements].filter(field => field.matches("input, select, textarea") && field.willValidate);
+ let valid = fields.filter(field => field.validity.valid).length;
+ this.binding.set("field-count", fields.length);
+ this.binding.set("valid-count", valid);
+ this.binding.set("invalid-count", fields.length - valid);
+ this.binding.set("all-valid", Number(valid === fields.length));
+ this.binding.set("valid-scalar", fields.length ? valid / fields.length : 1);
+ }
+
+ valueOf(control) {
+ if(control.matches('input[type="checkbox"], input[type="radio"]')) return control.checked;
+ return control.value;
+ }
+
+ onEvent(event) {
+ let control = this.control;
+ if(!control) return;
+ if(event.type === "reset" && (control.form === event.target || control === event.target)) {
+ // The reset default action runs after dispatch. Read the resulting values in the next frame.
+ if(!event.defaultPrevented) {
+ this.binding.state.dirty = false;
+ this.binding.state.touched = false;
+ }
+ this.binding.requestRefresh();
+ return;
+ }
+ let radioPeer = control.matches('input[type="radio"]')
+ && event.target.matches?.('input[type="radio"]')
+ && control.name !== "" && control.name === event.target.name
+ && control.form === event.target.form;
+ if(event.target !== control && event.target.form !== control && !radioPeer) return;
+ if(this.binding.name === "flux-field") {
+ if((event.type === "input" || event.type === "change")
+ && this.valueOf(control) !== this.binding.state.initial) this.binding.state.dirty = true;
+ if(event.type === "focusin" && event.target === control) this.binding.state.touched = true;
+ }
+ this.binding.requestRefresh();
+ }
+
+ dispose() {}
+}
diff --git a/src/CssProperties/FrameScheduler.es6 b/src/CssProperties/FrameScheduler.es6
new file mode 100644
index 0000000..694564f
--- /dev/null
+++ b/src/CssProperties/FrameScheduler.es6
@@ -0,0 +1,56 @@
+/** Coalesces measurements before writes, and sleeps whenever both queues are empty. */
+export class FrameScheduler {
+ constructor(windowObject = window, logger = console) {
+ this.window = windowObject;
+ this.logger = logger;
+ this.reads = new Set();
+ this.writes = new Set();
+ this.frame = null;
+ this.flushing = false;
+ this.onVisibility = () => {
+ if(this.window.document.hidden) this.cancel();
+ else this.schedule();
+ };
+ this.window.document.addEventListener("visibilitychange", this.onVisibility);
+ }
+
+ measure(callback) { this.reads.add(callback); this.schedule(); }
+ write(callback) { this.writes.add(callback); this.schedule(); }
+ forget(callback) { this.reads.delete(callback); this.writes.delete(callback); }
+
+ schedule() {
+ if(this.frame !== null || this.flushing || this.window.document.hidden) return;
+ if(!this.reads.size && !this.writes.size) return;
+ this.frame = this.window.requestAnimationFrame(this.flush);
+ }
+
+ flush = () => {
+ this.frame = null;
+ this.flushing = true;
+ this.run(this.reads);
+ this.run(this.writes);
+ this.flushing = false;
+ this.schedule();
+ }
+
+ run(queue) {
+ let callbacks = [...queue];
+ queue.clear();
+ for(let callback of callbacks) {
+ try { callback(); }
+ catch(error) { this.logger.error("Error updating Flux CSS properties:", error); }
+ }
+ }
+
+ cancel() {
+ if(this.frame !== null) this.window.cancelAnimationFrame(this.frame);
+ this.frame = null;
+ }
+
+ dispose() {
+ this.cancel();
+ this.reads.clear();
+ this.writes.clear();
+ this.window.document.removeEventListener("visibilitychange", this.onVisibility);
+ }
+}
diff --git a/src/CssProperties/GeometrySource.es6 b/src/CssProperties/GeometrySource.es6
new file mode 100644
index 0000000..e743e31
--- /dev/null
+++ b/src/CssProperties/GeometrySource.es6
@@ -0,0 +1,50 @@
+import {scalar} from "./ControlSource.es6";
+
+/** Measures the bound element, leaving inheritance to CSS. */
+export class GeometrySource {
+ constructor(binding) { this.binding = binding; }
+
+ refresh() {
+ let {name, element, runtime} = this.binding;
+ if(name === "flux-visible" || name === "flux-first-visible") {
+ let visible = this.binding.visible;
+ if(visible) this.binding.state.entered = true;
+ this.binding.set("", Number(name === "flux-visible" ? visible : Boolean(this.binding.state.entered)));
+ return;
+ }
+ if(name === "flux-truncated") {
+ let style = runtime.window.getComputedStyle(element);
+ let x = style.overflowX !== "visible" && element.scrollWidth > element.clientWidth;
+ let y = style.overflowY !== "visible" && element.scrollHeight > element.clientHeight;
+ this.binding.set("", Number(x || y));
+ this.binding.set("x", Number(x));
+ this.binding.set("y", Number(y));
+ return;
+ }
+ if(name === "flux-pointer-global") {
+ this.setPointerAxis("x", runtime.pointer.x, runtime.window.innerWidth);
+ this.setPointerAxis("y", runtime.pointer.y, runtime.window.innerHeight);
+ return;
+ }
+ let rectangle = element.getBoundingClientRect();
+ if(name === "flux-pointer") {
+ this.setPointerAxis("x", runtime.pointer.x - rectangle.left, rectangle.width);
+ this.setPointerAxis("y", runtime.pointer.y - rectangle.top, rectangle.height);
+ }
+ else {
+ let box = this.binding.resizeEntry?.borderBoxSize?.[0];
+ let vertical = box && runtime.window.getComputedStyle(element).writingMode.startsWith("vertical");
+ this.binding.set("x", box ? (vertical ? box.blockSize : box.inlineSize) : rectangle.width);
+ this.binding.set("y", box ? (vertical ? box.inlineSize : box.blockSize) : rectangle.height);
+ }
+ }
+
+ setPointerAxis(axis, position, size) {
+ let pixels = Math.max(0, Math.min(position, size));
+ this.binding.set(`${axis}-raw-px`, position);
+ this.binding.set(axis, size > 0 ? scalar(pixels / size) : 0);
+ this.binding.set(`${axis}-px`, pixels);
+ }
+
+ dispose() {}
+}
diff --git a/src/CssProperties/ObserverHub.es6 b/src/CssProperties/ObserverHub.es6
new file mode 100644
index 0000000..31909e9
--- /dev/null
+++ b/src/CssProperties/ObserverHub.es6
@@ -0,0 +1,41 @@
+/** Shares one browser observer among all subscribers, including visibility gates. */
+export class ObserverHub {
+ constructor(createObserver) {
+ this.createObserver = createObserver;
+ this.subscriptions = new Map();
+ this.observer = null;
+ }
+
+ observe(element, callback) {
+ if(!this.observer) this.observer = this.createObserver(this.dispatch);
+ let subscription = this.subscriptions.get(element);
+ if(!subscription) {
+ subscription = {callbacks: new Set(), entry: null};
+ this.subscriptions.set(element, subscription);
+ this.observer.observe(element);
+ }
+ subscription.callbacks.add(callback);
+ if(subscription.entry) callback(subscription.entry);
+ return () => {
+ subscription.callbacks.delete(callback);
+ if(subscription.callbacks.size) return;
+ this.observer.unobserve(element);
+ this.subscriptions.delete(element);
+ };
+ }
+
+ dispatch = entries => {
+ for(let entry of entries) {
+ let subscription = this.subscriptions.get(entry.target);
+ if(!subscription) continue;
+ subscription.entry = entry;
+ for(let callback of [...subscription.callbacks]) callback(entry);
+ }
+ }
+
+ dispose() {
+ this.observer?.disconnect();
+ this.subscriptions.clear();
+ this.observer = null;
+ }
+}
diff --git a/src/CssProperties/Palette.es6 b/src/CssProperties/Palette.es6
new file mode 100644
index 0000000..4e7abef
--- /dev/null
+++ b/src/CssProperties/Palette.es6
@@ -0,0 +1,51 @@
+function hex(channels) {
+ return "#" + channels.map(channel => Math.round(channel).toString(16).padStart(2, "0")).join("");
+}
+
+function brightness([red, green, blue]) { return (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255; }
+
+/** Extracts representative colours from a small RGBA sample. No DOM work belongs here. */
+export function extractPalette(pixels) {
+ let buckets = new Map();
+ let total = [0, 0, 0];
+ let count = 0;
+ for(let offset = 0; offset < pixels.length; offset += 4) {
+ if(pixels[offset + 3] < 128) continue;
+ let colour = [...pixels.slice(offset, offset + 3)];
+ let key = colour.map(channel => Math.floor(channel / 16)).join(",");
+ let bucket = buckets.get(key) ?? {count: 0, sum: [0, 0, 0]};
+ bucket.count++;
+ for(let channel = 0; channel < 3; channel++) {
+ bucket.sum[channel] += colour[channel];
+ total[channel] += colour[channel];
+ }
+ buckets.set(key, bucket);
+ count++;
+ }
+ if(!count) return null;
+ let colours = [...buckets.values()].map(bucket => ({
+ count: bucket.count,
+ channels: bucket.sum.map(channel => channel / bucket.count),
+ }));
+ let dominant = colours.reduce((best, colour) => colour.count > best.count ? colour : best);
+ let darkest = null;
+ let lightest = null;
+ let accent = dominant;
+ let greatestChroma = 0;
+ for(let colour of colours) {
+ let light = brightness(colour.channels);
+ let chroma = Math.max(...colour.channels) - Math.min(...colour.channels);
+ if(chroma > greatestChroma) { greatestChroma = chroma; accent = colour; }
+ if(light > 0.04 && (!darkest || light < brightness(darkest.channels))) darkest = colour;
+ if(light < 0.96 && (!lightest || light > brightness(lightest.channels))) lightest = colour;
+ }
+ let average = total.map(channel => channel / count);
+ return {
+ "": hex(dominant.channels),
+ accent: hex(accent.channels),
+ dark: hex((darkest ?? dominant).channels),
+ light: hex((lightest ?? dominant).channels),
+ average: hex(average),
+ temp: (average[0] - average[2]) / 255,
+ };
+}
diff --git a/src/CssProperties/PaletteSource.es6 b/src/CssProperties/PaletteSource.es6
new file mode 100644
index 0000000..665be9b
--- /dev/null
+++ b/src/CssProperties/PaletteSource.es6
@@ -0,0 +1,121 @@
+import {extractPalette} from "./Palette.es6";
+
+/** Samples loaded media at a bounded resolution, reusing one canvas for video frames. */
+export class PaletteSource {
+ static SAMPLE_SIZE = 16;
+ static VIDEO_INTERVAL = 250;
+
+ constructor(binding) {
+ this.binding = binding;
+ this.media = null;
+ this.context = null;
+ this.frame = null;
+ this.lastSample = -Infinity;
+ this.sampledSource = null;
+ this.failedSource = null;
+ this.generation = 0;
+ this.pending = false;
+ }
+
+ refresh() {
+ let element = this.binding.element;
+ let media = element.matches("img, video") ? element : element.querySelector("img, video");
+ if(media !== this.media) {
+ this.stop();
+ this.media = media;
+ this.sampledSource = null;
+ this.failedSource = null;
+ this.context = null;
+ this.binding.clear();
+ }
+ if(!media || !this.binding.active) { this.stop(); return; }
+ let source = media.currentSrc || media.src;
+ if(this.failedSource === source) return;
+ if(media.tagName === "IMG") {
+ if(!media.complete || !media.naturalWidth || this.sampledSource === source || this.pending) return;
+ this.sampleImage(source);
+ return;
+ }
+ let now = this.binding.runtime.window.performance.now();
+ if(media.readyState >= 2 && now - this.lastSample >= PaletteSource.VIDEO_INTERVAL) {
+ this.sample(media, source);
+ this.lastSample = now;
+ }
+ if(!media.paused && this.frame === null && media.requestVideoFrameCallback) {
+ this.frame = media.requestVideoFrameCallback(() => {
+ this.frame = null;
+ this.binding.requestRefresh();
+ });
+ }
+ }
+
+ async sampleImage(source) {
+ let generation = this.generation;
+ let media = this.media;
+ this.pending = true;
+ let bitmap;
+ try {
+ let createBitmap = this.binding.runtime.window.createImageBitmap;
+ if(createBitmap) {
+ try { bitmap = await createBitmap(media, {resizeWidth: 16, resizeHeight: 16}); }
+ catch { /* The canvas can downsample when bitmap decoding is unavailable. */ }
+ }
+ if(generation !== this.generation || (media.currentSrc || media.src) !== source) return;
+ this.sample(bitmap ?? media, source);
+ this.sampledSource = source;
+ }
+ finally {
+ bitmap?.close();
+ if(generation === this.generation) {
+ this.pending = false;
+ if((media.currentSrc || media.src) !== source) this.binding.requestRefresh();
+ }
+ }
+ }
+
+ sample(media, source) {
+ try {
+ if(!this.context) {
+ let canvas = this.binding.runtime.document.createElement("canvas");
+ canvas.width = canvas.height = PaletteSource.SAMPLE_SIZE;
+ this.context = canvas.getContext("2d", {willReadFrequently: true});
+ }
+ if(!this.context) { this.failedSource = source; return; }
+ this.context.drawImage(media, 0, 0, 16, 16);
+ let palette = extractPalette(this.context.getImageData(0, 0, 16, 16).data);
+ if(palette) {
+ for(let [name, value] of Object.entries(palette)) this.binding.set(name, value);
+ }
+ else this.binding.clear();
+ }
+ catch {
+ // Unreadable media (including a canvas blocked by CORS) leaves CSS fallbacks available.
+ this.failedSource = source;
+ this.context = null;
+ this.binding.clear();
+ }
+ }
+
+ onEvent(event) {
+ if(event.target !== this.media) return;
+ if(event.type === "seeked" || event.type === "loadeddata") this.lastSample = -Infinity;
+ if(event.type === "load") {
+ this.sampledSource = null;
+ this.failedSource = null;
+ }
+ if(event.type === "error" || event.type === "emptied") {
+ this.sampledSource = null;
+ this.binding.clear();
+ }
+ if(this.binding.active) this.binding.requestRefresh();
+ }
+
+ stop() {
+ if(this.frame !== null) this.media?.cancelVideoFrameCallback?.(this.frame);
+ this.frame = null;
+ this.generation++;
+ this.pending = false;
+ }
+
+ dispose() { this.stop(); }
+}
diff --git a/src/CssProperties/PropertyWriter.es6 b/src/CssProperties/PropertyWriter.es6
new file mode 100644
index 0000000..0068e95
--- /dev/null
+++ b/src/CssProperties/PropertyWriter.es6
@@ -0,0 +1,94 @@
+/** Owns individual declarations so disposal restores author styles and preserves other bindings. */
+export class PropertyWriter {
+ constructor(scheduler, logger = console) {
+ this.scheduler = scheduler;
+ this.logger = logger;
+ this.targets = new Map();
+ this.pending = new Set();
+ this.ownership = new Map();
+ }
+
+ set(owner, element, name, value) {
+ let properties = this.targets.get(element);
+ if(!properties) this.targets.set(element, properties = new Map());
+ let property = properties.get(name);
+ if(!property) {
+ property = {
+ element, name, owners: new Map(),
+ original: element.style.getPropertyValue(name),
+ priority: element.style.getPropertyPriority(name),
+ written: null,
+ };
+ properties.set(name, property);
+ }
+ let actual = element.style.getPropertyValue(name);
+ if(property.written !== null && property.written !== actual) {
+ property.original = actual;
+ property.priority = element.style.getPropertyPriority(name);
+ property.written = null;
+ }
+ if(!property.owners.has(owner) && property.owners.size) {
+ this.logger.warn(`Conflicting Flux CSS sources for ${name}; the first binding takes precedence.`, element);
+ }
+ let next = value === null ? "" : String(value);
+ if(property.owners.get(owner) === next && property.written === element.style.getPropertyValue(name)) return;
+ property.owners.set(owner, next);
+ let owned = this.ownership.get(owner);
+ if(!owned) this.ownership.set(owner, owned = new Set());
+ owned.add(property);
+ this.pending.add(property);
+ this.scheduler.write(this.flush);
+ }
+
+ flush = () => {
+ for(let property of this.pending) this.writeProperty(property);
+ this.pending.clear();
+ }
+
+ writeProperty(property) {
+ let value = property.owners.size ? property.owners.values().next().value : property.original;
+ let priority = property.owners.size ? "" : property.priority;
+ if(property.element.style.getPropertyValue(property.name) !== value
+ || property.element.style.getPropertyPriority(property.name) !== priority) {
+ property.element.style.setProperty(property.name, value, priority);
+ }
+ property.written = value;
+ if(!property.owners.size) {
+ let properties = this.targets.get(property.element);
+ properties?.delete(property.name);
+ if(!properties?.size) this.targets.delete(property.element);
+ }
+ }
+
+ release(owner, element = null) {
+ let owned = this.ownership.get(owner);
+ if(!owned) return;
+ for(let property of owned) {
+ if(element && property.element !== element) continue;
+ property.owners.delete(owner);
+ owned.delete(property);
+ if(property.owners.size) this.pending.add(property);
+ else {
+ // Cleanup must not retain removed elements until a hidden tab paints again.
+ this.pending.delete(property);
+ this.writeProperty(property);
+ }
+ }
+ if(!owned.size) this.ownership.delete(owner);
+ if(this.pending.size) this.scheduler.write(this.flush);
+ else this.scheduler.forget(this.flush);
+ }
+
+ dispose() {
+ for(let properties of this.targets.values()) {
+ for(let property of properties.values()) {
+ property.owners.clear();
+ this.pending.add(property);
+ }
+ }
+ this.flush();
+ this.targets.clear();
+ this.ownership.clear();
+ this.scheduler.forget(this.flush);
+ }
+}
diff --git a/src/CssProperties/Runtime.es6 b/src/CssProperties/Runtime.es6
new file mode 100644
index 0000000..9889001
--- /dev/null
+++ b/src/CssProperties/Runtime.es6
@@ -0,0 +1,200 @@
+import {Clock} from "./Clock.es6";
+import {ConnectionResolver} from "./ConnectionResolver.es6";
+import {DomPath} from "../DomPath.es6";
+import {FrameScheduler} from "./FrameScheduler.es6";
+import {PropertyWriter} from "./PropertyWriter.es6";
+import {ObserverHub} from "./ObserverHub.es6";
+import {Binding} from "./Binding.es6";
+import {CSS_SOURCES} from "./SourceRegistry.es6";
+
+const CONTROL_EVENTS = ["input", "change", "focusin", "reset", "invalid"];
+const MEDIA_EVENTS = ["load", "loadeddata", "timeupdate", "play", "pause", "seeked", "error", "emptied"];
+const POINTER_EVENTS = ["pointerdown", "pointermove"];
+
+
+/** Coordinates CSS bindings as Flux and other scripts change the document. */
+export class CssPropertyRuntime {
+ constructor(documentObject = document, logger = console) {
+ this.document = documentObject;
+ this.window = documentObject.defaultView;
+ this.logger = logger;
+ this.scheduler = new FrameScheduler(this.window, logger);
+ this.writer = new PropertyWriter(this.scheduler, logger);
+ this.clock = new Clock(this.window);
+ this.bindings = new Map();
+ this.connections = new ConnectionResolver(documentObject, logger);
+ this.history = new WeakMap();
+ this.pointer = {x: 0, y: 0};
+ this.listeners = [];
+ this.pointerAttached = false;
+ this.intersections = this.window.IntersectionObserver
+ ? new ObserverHub(callback => new this.window.IntersectionObserver(callback)) : null;
+ this.resizes = this.window.ResizeObserver
+ ? new ObserverHub(callback => new this.window.ResizeObserver(callback)) : null;
+ this.mutations = new this.window.MutationObserver(records => {
+ // CSS writes change style attributes. They must not feed back into binding discovery.
+ if(records.some(record => record.type !== "attributes" || record.attributeName !== "style")) this.synchronise();
+ });
+ this.mutations.observe(documentObject.documentElement, {
+ subtree: true, childList: true, characterData: true, attributes: true,
+ });
+ for(let name of [...CONTROL_EVENTS, ...MEDIA_EVENTS]) this.listen(documentObject, name, this.onEvent, true);
+ this.listen(documentObject, "visibilitychange", this.onVisibility);
+ this.listen(documentObject, "flux:before-render", this.beforeRender);
+ this.listen(documentObject, "flux:after-render", this.synchronise);
+ this.listen(this.window, "resize", this.refreshGeometry);
+ this.listen(documentObject, "scroll", this.onScroll, true);
+ this.document.fonts?.ready.then(() => { if(!this.disposed) this.refreshAll(); });
+ if(this.document.fonts) this.listen(this.document.fonts, "loadingdone", this.refreshAll);
+ }
+
+ listen(target, name, callback, capture = false) {
+ target.addEventListener(name, callback, {passive: true, capture});
+ this.listeners.push(() => target.removeEventListener(name, callback, capture));
+ }
+
+ observeVisibility(element, callback) {
+ if(element === this.document.documentElement || !this.intersections) {
+ callback(true);
+ return () => {};
+ }
+ return this.intersections.observe(element, entry => callback(entry.isIntersecting && entry.intersectionRatio > 0));
+ }
+
+ observeResize(element, callback) { return this.resizes?.observe(element, callback) ?? (() => {}); }
+
+ synchronise = () => {
+ if(this.disposed) return;
+ this.connections.begin();
+ let elements = new Set(this.document.querySelectorAll("[data-flux]"));
+ for(let [element, bindings] of this.bindings) {
+ if(elements.has(element)) continue;
+ for(let binding of bindings.values()) binding.dispose();
+ this.bindings.delete(element);
+ }
+ for(let element of elements) this.syncElement(element);
+ this.syncPointerListener();
+ }
+
+ syncElement(element) {
+ if(!element.isConnected || element.ownerDocument !== this.document) return;
+ let wanted;
+ try { wanted = this.connections.resolve(element); }
+ catch(error) { this.logger.error("Invalid Flux CSS declaration:", error); return; }
+ let bindings = this.bindings.get(element) ?? new Map();
+ for(let [name, binding] of bindings) {
+ if(wanted.has(name)) continue;
+ binding.dispose();
+ bindings.delete(name);
+ }
+ for(let [name, destinations] of wanted) {
+ let binding = bindings.get(name);
+ if(!binding) {
+ binding = new Binding(this, element, name, CSS_SOURCES[name], this.history.get(element)?.get(name));
+ bindings.set(name, binding);
+ }
+ binding.connect(destinations);
+ binding.requestRefresh();
+ }
+ if(bindings.size) this.bindings.set(element, bindings);
+ else this.bindings.delete(element);
+ this.history.delete(element);
+ }
+
+ forEach(callback) {
+ for(let bindings of this.bindings.values()) for(let binding of bindings.values()) callback(binding);
+ }
+
+ refreshAll = () => this.forEach(binding => binding.requestRefresh());
+ refreshGeometry = () => this.forEach(binding => { if(!binding.definition.always || binding.definition.scroll) binding.requestRefresh(); });
+ refreshPointers = () => this.forEach(binding => { if(binding.definition.pointer && binding.active) binding.requestRefresh(); });
+ onScroll = () => {
+ this.refreshPointers();
+ // Passage progress deliberately continues outside the visible 0–1 interval.
+ this.forEach(binding => { if(binding.definition.scroll) binding.requestRefresh(); });
+ }
+
+ onVisibility = () => {
+ if(this.document.hidden) this.forEach(binding => binding.source.stop?.());
+ else this.refreshAll();
+ }
+
+ onPointer = event => {
+ if(this.pointer.x === event.clientX && this.pointer.y === event.clientY) return;
+ this.pointer = {x: event.clientX, y: event.clientY};
+ this.refreshPointers();
+ }
+
+ syncPointerListener() {
+ let needed = false;
+ this.forEach(binding => { if(binding.definition.pointer) needed = true; });
+ if(needed === this.pointerAttached) return;
+ this.pointerAttached = needed;
+ for(let name of POINTER_EVENTS) {
+ if(needed) this.window.addEventListener(name, this.onPointer, {passive: true});
+ else this.window.removeEventListener(name, this.onPointer);
+ }
+ }
+
+ onEvent = event => {
+ if(event.type === "load") this.refreshGeometry();
+ // A reset can be cancelled by a later listener. Inspect it after dispatch finishes.
+ if(event.type === "reset") {
+ queueMicrotask(() => {
+ if(!event.defaultPrevented && !this.disposed) this.forEach(binding => binding.source.onEvent?.(event));
+ });
+ return;
+ }
+ this.forEach(binding => binding.source.onEvent?.(event));
+ }
+
+ beforeRender = event => {
+ let {updates, navigation = false} = event.detail;
+ for(let update of updates) {
+ if(navigation) {
+ this.forEach(binding => {
+ if(update.existingElement.contains(binding.element)) {
+ for(let key of Object.keys(binding.state)) delete binding.state[key];
+ }
+ });
+ continue;
+ }
+ if(!update.newElement || update.mode === "attributes") continue;
+ this.forEach(binding => this.preserveHistory(binding, update));
+ }
+ }
+
+ preserveHistory(binding, update) {
+ let {element, name, state} = binding;
+ if(!update.existingElement.contains(element)) return;
+ if(update.mode === "inner" && element === update.existingElement) return;
+ let replacement;
+ if(element === update.existingElement) replacement = update.newElement;
+ else if(element.id) {
+ replacement = [...update.newElement.querySelectorAll("[id]")].find(candidate => candidate.id === element.id);
+ }
+ else {
+ let path = DomPath.getXPathForElement(element, update.existingElement);
+ replacement = DomPath.findInContext(update.newElement, path);
+ }
+ if(!replacement || replacement.tagName !== element.tagName) return;
+ let history = this.history.get(replacement) ?? new Map();
+ history.set(name, {...state});
+ this.history.set(replacement, history);
+ }
+
+ dispose() {
+ this.disposed = true;
+ this.mutations.disconnect();
+ this.forEach(binding => binding.dispose());
+ this.bindings.clear();
+ this.connections.begin();
+ for(let name of POINTER_EVENTS) this.window.removeEventListener(name, this.onPointer);
+ for(let remove of this.listeners) remove();
+ this.intersections?.dispose();
+ this.resizes?.dispose();
+ this.writer.dispose();
+ this.clock.dispose();
+ this.scheduler.dispose();
+ }
+}
diff --git a/src/CssProperties/ScrollSource.es6 b/src/CssProperties/ScrollSource.es6
new file mode 100644
index 0000000..9685bec
--- /dev/null
+++ b/src/CssProperties/ScrollSource.es6
@@ -0,0 +1,84 @@
+/** Scroll offsets and an element's passage through its nearest scrollport. */
+export class ScrollSource {
+ constructor(binding) {
+ this.binding = binding;
+ this.observed = new Map();
+ }
+
+ refresh() {
+ let {element, runtime, name} = this.binding;
+ let {document, window} = runtime;
+ let page = document.scrollingElement ?? document.documentElement;
+ let roots = new Set([element]);
+ if(name === "flux-scroll") {
+ let container = element === document.body || element === document.documentElement ? page : element;
+ roots.add(container);
+ let style = window.getComputedStyle(container);
+ let reversedX = style.direction === "rtl";
+ let reversedY = false;
+ if(style.display.includes("flex")) {
+ if(style.flexDirection === "row-reverse") reversedX = !reversedX;
+ reversedY = style.flexDirection === "column-reverse";
+ }
+ for(let [axis, offset, extent, viewport] of [
+ ["x", container.scrollLeft, container.scrollWidth, container.clientWidth],
+ ["y", container.scrollTop, container.scrollHeight, container.clientHeight],
+ ]) {
+ let range = extent - viewport;
+ let direction = (axis === "x" ? reversedX : reversedY) ? -1 : 1;
+ this.binding.set(axis, range > 0 ? Math.max(0, Math.min(1, direction * offset / range)) : 0);
+ this.binding.set(`${axis}-px`, offset);
+ }
+ }
+ else {
+ let rectangle = element.getBoundingClientRect();
+ for(let [axis, start, size, border, client] of [
+ ["x", "left", "width", "clientLeft", "clientWidth"],
+ ["y", "top", "height", "clientTop", "clientHeight"],
+ ]) {
+ let container = this.scrollParent(axis);
+ let origin = 0;
+ let length = document.documentElement[client] || window[axis === "x" ? "innerWidth" : "innerHeight"];
+ if(container) {
+ let box = container.getBoundingClientRect();
+ origin = box[start] + container[border];
+ length = container[client];
+ }
+ roots.add(container ?? page);
+ let distance = length + rectangle[size];
+ let progress = distance > 0 ? (origin + length - rectangle[start]) / distance : 0;
+ this.binding.set(axis, progress);
+ this.binding.set(`${axis}-inverse`, 1 - progress);
+ this.binding.set(`${axis}-midway`, 1 - Math.abs(2 * progress - 1));
+ }
+ }
+ // Watch content as well as the fixed-size scrollport: content growth changes
+ // the scroll range even when the container itself has not resized.
+ let wanted = new Set(roots);
+ for(let root of roots) for(let child of root.children) wanted.add(child);
+ for(let [element, unsubscribe] of this.observed) {
+ if(wanted.has(element)) continue;
+ unsubscribe();
+ this.observed.delete(element);
+ }
+ for(let element of wanted) {
+ if(!this.observed.has(element)) this.observed.set(element, runtime.observeResize(element, this.binding.requestRefresh));
+ }
+ }
+
+ scrollParent(axis) {
+ let {element, runtime} = this.binding;
+ for(let parent = element.parentElement; parent && parent !== runtime.document.documentElement; parent = parent.parentElement) {
+ // Body declarations describe page scrolling, consistently with flux-scroll.
+ if(parent === runtime.document.body) break;
+ let style = runtime.window.getComputedStyle(parent);
+ if(/^(auto|scroll|hidden|overlay)$/.test(style[axis === "x" ? "overflowX" : "overflowY"])) return parent;
+ }
+ return null;
+ }
+
+ dispose() {
+ for(let unsubscribe of this.observed.values()) unsubscribe();
+ this.observed.clear();
+ }
+}
diff --git a/src/CssProperties/SourceRegistry.es6 b/src/CssProperties/SourceRegistry.es6
new file mode 100644
index 0000000..c56e9dd
--- /dev/null
+++ b/src/CssProperties/SourceRegistry.es6
@@ -0,0 +1,28 @@
+import {TimeSource} from "./TimeSource.es6";
+import {ControlSource} from "./ControlSource.es6";
+import {GeometrySource} from "./GeometrySource.es6";
+import {PaletteSource} from "./PaletteSource.es6";
+import {ScrollSource} from "./ScrollSource.es6";
+
+/** The complete CSS source catalogue: construction and scheduling policy in one place. */
+export const CSS_SOURCES = Object.freeze({
+ "flux-time": {source: TimeSource},
+ "flux-date": {source: TimeSource, properties: {"day-scalar": "--flux-day-scalar"}},
+ "flux-pointer": {source: GeometrySource, pointer: true, resize: true},
+ "flux-pointer-global": {source: GeometrySource, pointer: true},
+ "flux-size": {source: GeometrySource, resize: true},
+ "flux-visible": {source: GeometrySource, always: true},
+ "flux-first-visible": {source: GeometrySource, always: true},
+ "flux-scroll": {source: ScrollSource, scroll: true, always: true},
+ "flux-scroll-progress": {
+ source: ScrollSource, scroll: true, always: true,
+ properties: {"x-midway": "--flux-scroll-midway-x", "y-midway": "--flux-scroll-midway-y"},
+ },
+ "flux-range": {source: ControlSource, always: true},
+ "flux-select": {source: ControlSource, always: true},
+ "flux-color": {source: ControlSource, always: true},
+ "flux-field": {source: ControlSource, always: true},
+ "flux-form": {source: ControlSource, always: true},
+ "flux-palette": {source: PaletteSource},
+ "flux-truncated": {source: GeometrySource, resize: true},
+});
diff --git a/src/CssProperties/TimeSource.es6 b/src/CssProperties/TimeSource.es6
new file mode 100644
index 0000000..0047556
--- /dev/null
+++ b/src/CssProperties/TimeSource.es6
@@ -0,0 +1,63 @@
+import {calendarValues, timeValues} from "./CalendarValues.es6";
+
+/** Exposes local clock or calendar values through the normal CSS binding lifecycle. */
+export class TimeSource {
+ constructor(binding) {
+ this.binding = binding;
+ this.unsubscribe = null;
+ this.locale = null;
+ this.formatters = null;
+ this.nameDate = null;
+ this.names = {};
+ }
+
+ refresh() {
+ let {runtime, name} = this.binding;
+ this.unsubscribe ??= runtime.clock.subscribe(this.binding.requestRefresh);
+ let date = runtime.clock.now();
+ let values = name === "flux-time" ? timeValues(date) : calendarValues(date);
+ for(let [suffix, value] of Object.entries(values)) this.binding.set(suffix, value);
+ if(name === "flux-date") this.writeNames(date);
+ }
+
+ writeNames(date) {
+ let {element, runtime} = this.binding;
+ let locale = element.closest("[lang]")?.getAttribute("lang") || runtime.window.navigator.language;
+ if(!this.formatters || this.locale !== locale) {
+ this.locale = locale;
+ this.formatters = this.createFormatters(locale);
+ this.nameDate = null;
+ }
+ let day = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
+ if(this.nameDate !== day) {
+ this.nameDate = day;
+ for(let [suffix, formatter] of Object.entries(this.formatters)) {
+ // Names are CSS strings, ready for `content: var(--flux-date-month-name)`.
+ this.names[suffix] = JSON.stringify(formatter.format(date));
+ }
+ }
+ for(let [suffix, value] of Object.entries(this.names)) this.binding.set(suffix, value);
+ }
+
+ createFormatters(locale) {
+ let options = {
+ "month-name": {month: "long"},
+ "month-name-short": {month: "short"},
+ "day-name": {weekday: "long"},
+ "day-name-short": {weekday: "short"},
+ };
+ let formatters = {};
+ for(let [name, fields] of Object.entries(options)) {
+ try { formatters[name] = new Intl.DateTimeFormat(locale, {...fields, calendar: "gregory"}); }
+ catch { formatters[name] = new Intl.DateTimeFormat(undefined, {...fields, calendar: "gregory"}); }
+ }
+ return formatters;
+ }
+
+ stop() {
+ this.unsubscribe?.();
+ this.unsubscribe = null;
+ }
+
+ dispose() { this.stop(); }
+}
diff --git a/src/Debug.es6 b/src/Debug.es6
index a731ad7..8c914b2 100644
--- a/src/Debug.es6
+++ b/src/Debug.es6
@@ -1,9 +1,9 @@
import {RuntimeConfig} from "./RuntimeConfig.es6";
/**
- * Enables Flux debug logging when imported before the main browser bundle.
- * This flips the shared runtime flag that Flux passes into each
- * handler during startup.
+ * Legacy opt-in for applications constructing Flux directly from source.
+ * The browser entry point applies FluxConfig.debug explicitly so re-exporting
+ * this helper does not enable logging in the normal bundle.
*/
export class Debug {
static {
diff --git a/src/DialogHandler.es6 b/src/DialogHandler.es6
new file mode 100644
index 0000000..f6aecff
--- /dev/null
+++ b/src/DialogHandler.es6
@@ -0,0 +1,36 @@
+/** Opens server-rendered dialogs after Flux has inserted them into the document. */
+export class DialogHandler {
+ constructor(documentObject = globalThis.document) {
+ this.documentObject = documentObject;
+ this.opened = new WeakSet();
+ this.returnFocus = new WeakMap();
+ }
+
+ initModal = dialog => {
+ if(!(dialog instanceof HTMLDialogElement)) {
+ throw new TypeError('data-flux type "modal" requires a dialog element.');
+ }
+ let returnFocus = this.documentObject.activeElement;
+ // DOM updates initialise directives before insertion and restore focus afterwards.
+ queueMicrotask(() => {
+ if(!dialog.isConnected || dialog.ownerDocument !== this.documentObject || this.opened.has(dialog)) return;
+ if(typeof dialog.showModal !== "function") return;
+ if(!dialog.matches(":modal")) {
+ // An open attribute supplies a visible non-modal fallback without JavaScript.
+ dialog.removeAttribute("open");
+ dialog.showModal();
+ this.returnFocus.set(dialog, returnFocus);
+ dialog.addEventListener("close", this.onClose, {once: true});
+ }
+ this.opened.add(dialog);
+ });
+ }
+
+ onClose = event => {
+ let dialog = event.currentTarget;
+ let target = this.returnFocus.get(dialog);
+ this.returnFocus.delete(dialog);
+ // A shared listener can be transferred without retaining a previous dialog.
+ if(target?.isConnected && !dialog.contains(target)) target.focus({preventScroll: true});
+ }
+}
diff --git a/src/DirectiveParser.es6 b/src/DirectiveParser.es6
new file mode 100644
index 0000000..992fa43
--- /dev/null
+++ b/src/DirectiveParser.es6
@@ -0,0 +1,45 @@
+/** Reads whitespace-separated directives without splitting CSS connection selectors. */
+export class DirectiveParser {
+ static parse(value = "") {
+ let tokens = [];
+ let start = 0;
+ let depth = 0;
+ let quote = null;
+ let escaped = false;
+ for(let index = 0; index < value.length; index++) {
+ let character = value[index];
+ if(escaped) { escaped = false; continue; }
+ if(character === "\\") { escaped = true; continue; }
+ if(quote) {
+ if(character === quote) quote = null;
+ continue;
+ }
+ if(character === '"' || character === "'") { quote = character; continue; }
+ if(character === "(") depth++;
+ if(character === ")" && --depth < 0) throw new SyntaxError("Unexpected closing Flux connection bracket.");
+ if(/\s/.test(character) && depth === 0) {
+ if(index > start) tokens.push(value.slice(start, index));
+ start = index + 1;
+ }
+ }
+ if(depth || quote || escaped) throw new SyntaxError("Unclosed Flux connection or quoted selector.");
+ if(start < value.length) tokens.push(value.slice(start));
+ return [...new Set(tokens)].map(token => this.parseToken(token));
+ }
+
+ static parseToken(token) {
+ if(!token.startsWith("(")) return {names: [token], selector: null};
+ let separator = token.indexOf("@");
+ if(separator < 2 || !token.endsWith(")")) throw new SyntaxError(`Invalid Flux connection: ${token}`);
+ let names = [...new Set(token.slice(1, separator).split(",").map(name => name.trim()))];
+ let selector = token.slice(separator + 1, -1).trim();
+ if(!selector || names.some(name => !/^flux-[a-z-]+$/.test(name))) {
+ throw new SyntaxError(`Invalid Flux connection: ${token}`);
+ }
+ return {names, selector};
+ }
+
+ static has(element, name) {
+ return this.parse(element.dataset["flux"]).some(token => !token.selector && token.names.includes(name));
+ }
+}
diff --git a/src/DirectiveRegistry.es6 b/src/DirectiveRegistry.es6
index a246c63..41e6137 100644
--- a/src/DirectiveRegistry.es6
+++ b/src/DirectiveRegistry.es6
@@ -1,4 +1,11 @@
+import {DirectiveParser} from "./DirectiveParser.es6";
+import {CSS_SOURCES} from "./CssProperties/SourceRegistry.es6";
+
const DIRECTIVE_DEFINITIONS = Object.freeze({
+ ...Object.fromEntries(Object.keys(CSS_SOURCES).map(name => [name, {
+ handler: "cssProperties", description: `Expose ${name} CSS properties.`,
+ }])),
+ "auto": {handler: "autoContainer", description: "Enable automatic Flux interactions."},
"": {
handler: "autoContainer",
description: "Initialise a container element for automatic Flux interactions.",
@@ -59,6 +66,7 @@ const DIRECTIVE_DEFINITIONS = Object.freeze({
handler: "autoLink",
description: "Follow the link in the background.",
},
+ "modal": {handler: "modal", description: "Open a server-rendered dialog as a modal."},
"drag-order": {
handler: "dragOrder",
description: "Turn a server-ordered form into a draggable ordering control.",
@@ -78,24 +86,29 @@ export class DirectiveRegistry {
}
initElement(fluxElement) {
- let fluxType = fluxElement.dataset["flux"];
- if(fluxType === "") {
- if(fluxElement instanceof HTMLButtonElement) {
- fluxType = "submit";
+ let declarations = DirectiveParser.parse(fluxElement.dataset["flux"]);
+ if(!declarations.length) declarations = [{names: [""], selector: null}];
+ let invoked = new Set();
+ let errors = [];
+ for(let declaration of declarations) {
+ for(let name of declaration.names) {
+ try {
+ if(declaration.selector && !Object.hasOwn(CSS_SOURCES, name)) {
+ throw new TypeError(`Only CSS sources can be connected: ${name}`);
+ }
+ if((name === "" || name === "auto") && fluxElement instanceof HTMLButtonElement) name = "submit";
+ let definition = Object.hasOwn(DirectiveRegistry.DEFINITIONS, name) ? DirectiveRegistry.DEFINITIONS[name] : null;
+ if(!definition) throw new TypeError(`Unknown flux element type: ${name}`);
+ if(invoked.has(definition.handler)) continue;
+ let handler = this.handlers[definition.handler];
+ if(typeof handler !== "function") throw new TypeError(`Missing Flux directive handler: ${definition.handler}`);
+ handler(fluxElement);
+ invoked.add(definition.handler);
+ }
+ catch(error) { errors.push(error); }
}
}
-
- let definition = DirectiveRegistry.DEFINITIONS[fluxType];
- if(!definition) {
- throw new TypeError(`Unknown flux element type: ${fluxType}`);
- }
-
- let handler = this.handlers[definition.handler];
- if(typeof handler !== "function") {
- throw new TypeError(`Missing Flux directive handler: ${definition.handler}`);
- }
-
- handler(fluxElement);
+ if(errors.length) throw errors[0];
}
getDefinitions() {
diff --git a/src/DocumentUpdater.es6 b/src/DocumentUpdater.es6
index 47a0d20..38945b8 100644
--- a/src/DocumentUpdater.es6
+++ b/src/DocumentUpdater.es6
@@ -24,7 +24,7 @@ export class DocumentUpdater {
this.debug = debug;
}
- apply(newDocument, allowedTypes = undefined, allowedTargetKeys = undefined, requestElementState = null) {
+ apply(newDocument, allowedTypes = undefined, allowedTargetKeys = undefined, requestElementState = null, navigation = false) {
this.focusStateManager.markAutofocus(newDocument);
let newActiveElement = this.focusStateManager.capturePendingActiveElement(newDocument);
let allowedTypeSet = allowedTypes ? new Set(allowedTypes) : null;
@@ -58,7 +58,7 @@ export class DocumentUpdater {
updates = this.withoutTargetsDisconnectedByOuterUpdates(updates);
if(updates.length > 0) {
- this.dispatchFluxEvent("flux:before-render", {updates});
+ this.dispatchFluxEvent("flux:before-render", {updates, navigation});
updates.forEach(update => this.applyUpdate(update, requestElementState));
this.dispatchFluxEvent("flux:after-render", {updates});
}
@@ -144,7 +144,8 @@ export class DocumentUpdater {
}
applyInnerUpdate(existingElement, newElement) {
- this.prepareElementUpdate(existingElement, newElement);
+ // The existing root stays in place; only initialise children that will be inserted.
+ this.prepareElementUpdate(existingElement, newElement, false);
while(existingElement.firstChild) {
existingElement.removeChild(existingElement.firstChild);
diff --git a/src/DomBridge.es6 b/src/DomBridge.es6
index 2839b8c..825491e 100644
--- a/src/DomBridge.es6
+++ b/src/DomBridge.es6
@@ -22,21 +22,21 @@ export class DomBridge {
this.documentObject = documentObject;
}
- prepareElementUpdate = (oldElement, newElement) => {
+ prepareElementUpdate = (oldElement, newElement, includeRoot = true) => {
if(!newElement) {
return;
}
- this.reattachEventListeners(oldElement, newElement);
- this.reattachFluxElements(oldElement, newElement);
+ this.reattachEventListeners(oldElement, newElement, includeRoot);
+ this.reattachFluxElements(oldElement, newElement, includeRoot);
}
- reattachEventListeners(oldElement, newElement) {
+ reattachEventListeners(oldElement, newElement, includeRoot = true) {
if(!newElement) {
return;
}
- this.reattachElementListeners(oldElement, newElement);
+ if(includeRoot) this.reattachElementListeners(oldElement, newElement);
oldElement.querySelectorAll("*").forEach(oldChild => {
let xPath = this.domPath.getXPathForElement(oldChild, oldElement);
@@ -64,12 +64,12 @@ export class DomBridge {
}
}
- reattachFluxElements(oldElement, newElement) {
+ reattachFluxElements(oldElement, newElement, includeRoot = true) {
if(!newElement) {
return;
}
- if(newElement.matches?.("[data-flux]")) {
+ if(includeRoot && newElement.matches?.("[data-flux]")) {
this.initFluxElement(newElement);
}
diff --git a/src/DragOrder/DropTargetResolver.es6 b/src/DragOrder/DropTargetResolver.es6
index 9394ddf..32cb024 100644
--- a/src/DragOrder/DropTargetResolver.es6
+++ b/src/DragOrder/DropTargetResolver.es6
@@ -42,7 +42,7 @@ export class DropTargetResolver {
return null;
}
- let host = element.closest("[data-flux='drag-order']");
+ let host = element.closest("[data-flux~='drag-order']");
if(!host || host === dragState.item) {
return null;
}
diff --git a/src/DragOrder/Handler.es6 b/src/DragOrder/Handler.es6
index 6f34e60..b20122d 100644
--- a/src/DragOrder/Handler.es6
+++ b/src/DragOrder/Handler.es6
@@ -67,6 +67,12 @@ export class Handler {
handle.tabIndex = 0;
handle.ariaLabel = "Drag to reorder";
handle.dataset["fluxTitle"] = handleTitle;
+ if(this.getDragAxis(form, item) === "y") {
+ handle.style.cursor = "ns-resize";
+ }
+ else if(this.getDragAxis(form, item) === "x") {
+ handle.style.cursor = "ew-resize";
+ }
form.prepend(handle);
handle.addEventListener("dragstart", e => this.startNativeDrag(e, form, item));
@@ -88,6 +94,11 @@ export class Handler {
?? "Drag";
}
+ getDragAxis(form, item) {
+ return form.closest("[data-flux-drag-axis]")?.dataset["fluxDragAxis"]
+ ?? item.closest("[data-flux-drag-axis]")?.dataset["fluxDragAxis"];
+ }
+
initContainer(container) {
if(this.containerState.has(container)) {
return;
@@ -152,6 +163,9 @@ export class Handler {
this.dragState = {
form,
item,
+ axis: this.getDragAxis(form, item),
+ initialClientX: clientX,
+ initialClientY: clientY,
floatingItem: this.preview.create(item, rect),
initialContainer: item.parentElement,
container: item.parentElement,
@@ -212,6 +226,14 @@ export class Handler {
}
moveItem(clientY, container = this.dragState.container, clientX = null) {
+ if(this.dragState.axis === "y") {
+ container = this.dragState.initialContainer;
+ clientX = this.dragState.initialClientX;
+ }
+ else if(this.dragState.axis === "x") {
+ container = this.dragState.initialContainer;
+ clientY = this.dragState.initialClientY;
+ }
if(!(container instanceof HTMLElement)) {
container = this.dragState.container;
}
@@ -219,7 +241,8 @@ export class Handler {
let {item, pointerOffsetX, pointerOffsetY} = this.dragState;
let sortableItems = this.sortableItems.getSiblings(container);
let siblings = sortableItems.filter(child => child !== item);
- let horizontal = this.sortableItems.isHorizontal(sortableItems);
+ let horizontal = this.dragState.axis === "x"
+ || (this.dragState.axis !== "y" && this.sortableItems.isHorizontal(sortableItems));
let itemCenter = horizontal
? (clientX ?? 0) + pointerOffsetX
: clientY + pointerOffsetY;
diff --git a/src/DragOrder/SortableItems.es6 b/src/DragOrder/SortableItems.es6
index 9949cf0..1b2b3ef 100644
--- a/src/DragOrder/SortableItems.es6
+++ b/src/DragOrder/SortableItems.es6
@@ -1,3 +1,4 @@
+import {DirectiveParser} from "../DirectiveParser.es6";
export const LEGACY_SORTABLE_ITEM_ATTRIBUTE = "data-flux-drag-order-item";
/**
@@ -11,7 +12,7 @@ export class SortableItems {
}
isSortable(child) {
- return child.dataset["flux"] === "drag-order"
+ return DirectiveParser.has(child, "drag-order")
|| child.hasAttribute(LEGACY_SORTABLE_ITEM_ATTRIBUTE);
}
diff --git a/src/Flux.es6 b/src/Flux.es6
index a34267a..7206f13 100644
--- a/src/Flux.es6
+++ b/src/Flux.es6
@@ -1,3 +1,5 @@
+import {DialogHandler} from "./DialogHandler.es6";
+import {CssPropertyRuntime} from "./CssProperties/Runtime.es6";
import {Style} from "./Style.es6";
import {ElementEventMapper} from "./ElementEventMapper.es6";
import {DomPath} from "./DomPath.es6";
@@ -86,7 +88,7 @@ export class Flux {
this.documentUpdater = documentUpdater ?? new DocumentUpdater(
this.updateTargetRegistry,
this.focusStateManager,
- (oldElement, newElement) => this.domBridge.prepareElementUpdate(oldElement, newElement),
+ (oldElement, newElement, includeRoot) => this.domBridge.prepareElementUpdate(oldElement, newElement, includeRoot),
element => this.domBridge.reviveScripts(element),
DomPath,
this.logger,
@@ -127,6 +129,10 @@ export class Flux {
this.navigationController,
this.logger,
Flux.DEBUG,
+ undefined,
+ undefined,
+ undefined,
+ this.initAutocompleteResultElements,
);
this.dragOrderHandler = dragOrderHandler ?? new DragOrderHandler(
this.formHandler,
@@ -134,8 +140,10 @@ export class Flux {
this.logger,
Flux.DEBUG,
);
+ this.dialogHandler = new DialogHandler();
this.directiveRegistry = directiveRegistry ?? new DirectiveRegistry({
autoContainer: this.initAutoContainer,
+ cssProperties: this.initCssProperties,
autoSave: this.formHandler.initAutoSave,
updateOuter: this.storeOuterUpdateElement,
updateInner: this.storeInnerUpdateElement,
@@ -149,9 +157,11 @@ export class Flux {
autocompleteResults: this.autocompleteHandler.initAutocompleteResults,
autoLink: this.linkHandler.initAutoLink,
dragOrder: this.dragOrderHandler.initDragOrder,
+ modal: this.dialogHandler.initModal,
});
document.querySelectorAll("[data-flux]").forEach(this.initFluxElementSafely);
+ if(!this.cssPropertyRuntime) document.addEventListener("flux:after-render", this.initRenderedCssProperties);
}
/**
@@ -174,6 +184,29 @@ export class Flux {
}
}
+ initAutocompleteResultElements = element => {
+ if(element.matches("[data-flux]")) this.initFluxElementSafely(element);
+ element.querySelectorAll("[data-flux]").forEach(this.initFluxElementSafely);
+ }
+
+ initRenderedCssProperties = event => {
+ if(this.cssPropertyRuntime) return;
+ for(let update of event.detail.updates) this.initCssPropertiesInTree(update.element);
+ }
+
+ initCssPropertiesInTree = element => {
+ if(this.cssPropertyRuntime || !element) return;
+ let selector = '[data-flux*="flux-"]';
+ if(element.matches(selector) || element.querySelector(selector)) this.initCssProperties();
+ }
+
+ initCssProperties = () => {
+ if(this.cssPropertyRuntime) return;
+ document.removeEventListener("flux:after-render", this.initRenderedCssProperties);
+ this.cssPropertyRuntime = new CssPropertyRuntime(document, this.logger);
+ queueMicrotask(() => this.cssPropertyRuntime.synchronise());
+ }
+
initAutoContainer = (fluxElement) => {
if(fluxElement instanceof HTMLFormElement) {
this.formHandler.initAutoContainer(fluxElement);
diff --git a/src/FocusStateManager.es6 b/src/FocusStateManager.es6
index 0692118..04d8f75 100644
--- a/src/FocusStateManager.es6
+++ b/src/FocusStateManager.es6
@@ -97,7 +97,8 @@ export class FocusStateManager {
elementToActivate.checked = elementState.checked;
}
- elementToActivate.focus();
+ // Restoring focus must not pull an off-screen field back into view.
+ elementToActivate.focus({preventScroll: true});
if(elementState.selection && elementToActivate.setSelectionRange) {
elementToActivate.setSelectionRange(
elementState.selection[0],
@@ -162,7 +163,7 @@ export class FocusStateManager {
return;
}
- newActiveElement.focus();
+ newActiveElement.focus({preventScroll: true});
}
focusMarkedAutofocusElements() {
diff --git a/src/LiveHandler.es6 b/src/LiveHandler.es6
index 7b19cf0..adae0c1 100644
--- a/src/LiveHandler.es6
+++ b/src/LiveHandler.es6
@@ -1,3 +1,5 @@
+import {LiveVisibility} from "./LiveVisibility.es6";
+
/**
* Runs recurring background refreshes for live Flux regions.
* Registers live update targets, schedules polling based on
@@ -21,6 +23,7 @@ export class LiveHandler {
intervalMs = 1000,
now = () => Date.now(),
domPath = null,
+ documentObject = globalThis.document,
) {
this.navigationController = navigationController;
this.updateTargetRegistry = updateTargetRegistry;
@@ -36,6 +39,8 @@ export class LiveHandler {
this.timerId = null;
this.inFlight = false;
this.lastRefreshMap = new Map();
+ this.visibility = new LiveVisibility(this.refreshSchedule, documentObject);
+ this.disposed = false;
}
register(updateType, element) {
@@ -46,10 +51,28 @@ export class LiveHandler {
this.lastRefreshMap.set(key, this.now());
}
+ this.visibility.observe(element);
+ // Replacement elements are initialised before adoption into the current document.
+ if(this.visibility.document.contains(element)) this.ensureRunning();
+ else queueMicrotask(this.refreshSchedule);
+ }
+
+ refreshSchedule = () => {
+ if(this.disposed) return;
+ this.visibility.prune();
+ let keys = new Set();
+ for(let type of LiveHandler.UPDATE_TYPES) {
+ for(let element of this.getConnectedElements(type)) keys.add(this.getTargetKey(type, element));
+ }
+ for(let key of this.lastRefreshMap.keys()) {
+ if(!keys.has(key)) this.lastRefreshMap.delete(key);
+ }
+ this.stop();
this.ensureRunning();
}
ensureRunning() {
+ if(this.disposed || this.inFlight || this.visibility.document.hidden) return;
let nextDelay = this.getNextPollDelay();
if(this.timerId !== null || nextDelay === null) {
return;
@@ -69,17 +92,13 @@ export class LiveHandler {
pollDocument = async() => {
this.timerId = null;
+ if(this.disposed || this.inFlight) return;
let dueTargets = this.getDueTargets();
if(dueTargets.length === 0) {
this.ensureRunning();
return;
}
- if(this.inFlight) {
- this.ensureRunning();
- return;
- }
-
this.inFlight = true;
try {
let targetsRefreshed = false;
@@ -88,7 +107,10 @@ export class LiveHandler {
newDocument => {
this.markTargetsRefreshed(dueTargets);
targetsRefreshed = true;
- this.onDocument(newDocument, dueTargets.map(target => target.key));
+ let activeTargets = dueTargets.filter(target => this.visibility.isActive(target.element));
+ if(!this.disposed && activeTargets.length) {
+ this.onDocument(newDocument, activeTargets.map(target => target.key));
+ }
},
);
@@ -98,11 +120,12 @@ export class LiveHandler {
}
finally {
this.inFlight = false;
- this.ensureRunning();
+ this.refreshSchedule();
}
}
markTargetsRefreshed(targets) {
+ if(this.disposed) return;
let refreshedAt = this.now();
for(let target of targets) {
this.lastRefreshMap.set(target.key, refreshedAt);
@@ -140,6 +163,7 @@ export class LiveHandler {
for(let type of LiveHandler.UPDATE_TYPES) {
for(let element of this.getConnectedElements(type)) {
+ if(!this.visibility.isActive(element)) continue;
let key = this.getTargetKey(type, element);
let rateMs = this.getRateMs(element);
let lastRefresh = this.lastRefreshMap.get(key) ?? -Infinity;
@@ -159,6 +183,7 @@ export class LiveHandler {
for(let type of LiveHandler.UPDATE_TYPES) {
for(let element of this.getConnectedElements(type)) {
+ if(!this.visibility.isActive(element)) continue;
hasTargets = true;
let key = this.getTargetKey(type, element);
let rateMs = this.getRateMs(element);
@@ -178,7 +203,7 @@ export class LiveHandler {
getConnectedElements(type) {
let connected = [];
for(let element of [...this.updateTargetRegistry.getElements(type)]) {
- if(element?.isConnected) {
+ if(this.visibility.document.contains(element)) {
connected.push(element);
continue;
}
@@ -190,6 +215,13 @@ export class LiveHandler {
return connected;
}
+ dispose() {
+ this.disposed = true;
+ this.stop();
+ this.visibility.dispose();
+ this.lastRefreshMap.clear();
+ }
+
getRateMs(element) {
let rateSeconds = Number.parseFloat(element.dataset["fluxRate"] ?? "");
if(!Number.isFinite(rateSeconds) || rateSeconds <= 0) {
diff --git a/src/LiveVisibility.es6 b/src/LiveVisibility.es6
new file mode 100644
index 0000000..ebf0e27
--- /dev/null
+++ b/src/LiveVisibility.es6
@@ -0,0 +1,52 @@
+/** Watches live targets without a scroll listener or a recurring visibility check. */
+export class LiveVisibility {
+ constructor(onChange, documentObject = globalThis.document) {
+ this.document = documentObject;
+ this.onChange = onChange;
+ this.elements = new Set();
+ this.visible = new WeakSet();
+ this.started = false;
+ let windowObject = documentObject.defaultView;
+ this.observer = windowObject.IntersectionObserver
+ ? new windowObject.IntersectionObserver(entries => {
+ for(let entry of entries) {
+ if(entry.isIntersecting && entry.intersectionRatio > 0) this.visible.add(entry.target);
+ else this.visible.delete(entry.target);
+ }
+ this.onChange();
+ }) : null;
+ this.mutations = new windowObject.MutationObserver(onChange);
+ }
+
+ observe(element) {
+ if(this.elements.has(element)) return;
+ if(!this.started) {
+ this.started = true;
+ this.document.addEventListener("visibilitychange", this.onChange);
+ this.mutations.observe(this.document.documentElement, {childList: true, subtree: true});
+ }
+ this.elements.add(element);
+ this.observer?.observe(element);
+ }
+
+ isActive(element) {
+ return !this.document.hidden && this.document.contains(element)
+ && (!this.observer || this.visible.has(element));
+ }
+
+ prune() {
+ for(let element of this.elements) {
+ if(this.document.contains(element)) continue;
+ this.observer?.unobserve(element);
+ this.elements.delete(element);
+ this.visible.delete(element);
+ }
+ }
+
+ dispose() {
+ this.observer?.disconnect();
+ this.mutations.disconnect();
+ this.document.removeEventListener("visibilitychange", this.onChange);
+ this.elements.clear();
+ }
+}
diff --git a/src/NavigationController.es6 b/src/NavigationController.es6
index df6ac0f..2f9321c 100644
--- a/src/NavigationController.es6
+++ b/src/NavigationController.es6
@@ -83,8 +83,9 @@ export class NavigationController {
{
action: "clickLink",
errorPrefix: "Link fetch error",
- scrollX: 0,
- scrollY: 0,
+ scrollX: scrollState.preserve ? scrollState.x : 0,
+ scrollY: scrollState.preserve ? scrollState.y : 0,
+ preserveScroll: scrollState.preserve,
scrollBehavior: scrollState.behavior,
scrollPath: scrollState.path,
},
@@ -113,6 +114,8 @@ export class NavigationController {
waitingElement?.classList?.add(className);
}
+ historyState.updateHistory = element?.closest?.("[data-flux-history]")?.dataset.fluxHistory !== "false";
+
try {
return await this.requestDocument(url, requestOptions, historyState, onDocument, true);
}
@@ -191,7 +194,7 @@ export class NavigationController {
let html = await response.text();
let document = this.parser.parseFromString(html, "text/html");
- if(historyState.action) {
+ if(historyState.action && historyState.updateHistory !== false) {
this.storeScrollPositionForCurrentEntry(historyState);
this.historyObject.pushState(
this.createHistoryState(historyState),
@@ -219,6 +222,8 @@ export class NavigationController {
action: historyState.action,
};
+ if(historyState.preserveScroll) state.fluxScrollPreserve = true;
+
if(Number.isFinite(historyState.scrollY)) {
state.fluxScrollX = Number.isFinite(historyState.scrollX) ? historyState.scrollX : 0;
state.fluxScrollY = historyState.scrollY;
@@ -264,6 +269,11 @@ export class NavigationController {
getScrollStateForElement(element) {
let scrollElement = element?.closest?.("[data-flux-scroll]");
+ let preserve = scrollElement?.dataset?.fluxScroll === "preserve";
+ // A preserve marker changes navigation policy, not the scrolling element.
+ while(scrollElement?.dataset?.fluxScroll === "preserve") {
+ scrollElement = scrollElement.parentElement?.closest("[data-flux-scroll]");
+ }
let behavior = scrollElement?.dataset?.fluxScroll;
if(behavior !== "smooth" && behavior !== "auto") {
behavior = null;
@@ -271,6 +281,7 @@ export class NavigationController {
if(scrollElement && scrollElement !== this.documentObject?.body && scrollElement !== this.documentObject?.documentElement) {
return {
+ ...(preserve ? {preserve: true} : {}),
x: scrollElement.scrollLeft,
y: scrollElement.scrollTop,
behavior,
@@ -279,6 +290,7 @@ export class NavigationController {
}
return {
+ ...(preserve ? {preserve: true} : {}),
x: this.windowObject?.scrollX ?? 0,
y: this.windowObject?.scrollY ?? 0,
behavior,
diff --git a/src/ResponseHandler.es6 b/src/ResponseHandler.es6
index 0bcca5b..1eb5676 100644
--- a/src/ResponseHandler.es6
+++ b/src/ResponseHandler.es6
@@ -77,9 +77,10 @@ export class ResponseHandler {
ResponseHandler.LINK_UPDATE_TYPES,
undefined,
elementState,
+ true,
);
this.onLiveDocumentUsed();
- this.scrollToTopAfterPaint(scrollState);
+ this.scrollAfterPaint(scrollState);
}, 0);
}
@@ -113,11 +114,14 @@ export class ResponseHandler {
&& (Number.isFinite(state.fluxScrollY) || state.action === "clickLink");
}
- scrollToTopImmediately(scrollState = null) {
+ applyScrollPosition(scrollState = null) {
let scrollTarget = this.getScrollTarget(scrollState);
- let behavior = scrollState?.fluxScrollBehavior ?? RuntimeConfig.scrollToTopBehavior;
+ let preserve = scrollState?.fluxScrollPreserve;
+ let top = preserve ? scrollState.fluxScrollY : 0;
+ let left = preserve ? scrollState.fluxScrollX : 0;
+ let behavior = preserve ? "instant" : scrollState?.fluxScrollBehavior ?? RuntimeConfig.scrollToTopBehavior;
if(scrollTarget?.element) {
- this.scrollElementTo(scrollTarget.element, 0, 0, behavior);
+ this.scrollElementTo(scrollTarget.element, top, left, behavior);
return;
}
@@ -126,8 +130,8 @@ export class ResponseHandler {
}
this.windowObject.scrollTo({
- top: 0,
- left: 0,
+ top,
+ left,
behavior,
});
}
@@ -156,10 +160,10 @@ export class ResponseHandler {
};
}
- scrollToTopAfterPaint(scrollState = null) {
+ scrollAfterPaint(scrollState = null) {
if(typeof this.animationFrame !== "function") {
this.scheduler(() => {
- this.scrollToTopImmediately(scrollState);
+ this.applyScrollPosition(scrollState);
}, 0);
return;
}
@@ -169,7 +173,7 @@ export class ResponseHandler {
// scroll animation against the new layout.
this.animationFrame(() => {
this.animationFrame(() => {
- this.scrollToTopImmediately(scrollState);
+ this.applyScrollPosition(scrollState);
});
});
}
diff --git a/src/Style.es6 b/src/Style.es6
index e4e66ae..3b2d4ae 100644
--- a/src/Style.es6
+++ b/src/Style.es6
@@ -23,7 +23,7 @@ export class Style {
}
const CSS_CONTENT = `
-[data-flux="autosave"] {
+[data-flux~="autosave"] {
display: none;
}
diff --git a/src/main.es6 b/src/main.es6
index a9b5dc5..d07809f 100644
--- a/src/main.es6
+++ b/src/main.es6
@@ -2,5 +2,6 @@ import { Flux } from "./Flux.es6";
import { RuntimeConfig } from "./RuntimeConfig.es6";
export { Debug, Debug as FluxDebug } from "./Debug.es6";
-RuntimeConfig.configure(globalThis.FluxConfig);
+// Re-exporting Debug evaluates its static initialiser too. Normal startup is quiet.
+RuntimeConfig.configure({debug: false, ...globalThis.FluxConfig});
new Flux();
diff --git a/test/Bootstrap.test.js b/test/Bootstrap.test.js
new file mode 100644
index 0000000..8de52db
--- /dev/null
+++ b/test/Bootstrap.test.js
@@ -0,0 +1,23 @@
+import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
+
+vi.mock("../src/Flux.es6", () => ({Flux: vi.fn()}));
+beforeEach(() => { vi.resetModules(); });
+afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); });
+
+describe("Browser bundle configuration", () => {
+ it("does not enable debug logging merely by exporting the debug helper", async () => {
+ vi.stubGlobal("FluxConfig", undefined);
+ await import("../src/main.es6");
+ let {RuntimeConfig} = await import("../src/RuntimeConfig.es6");
+ expect(RuntimeConfig.debug).toBe(false);
+ });
+ it("applies an explicit debug setting before initialising Flux", async () => {
+ vi.stubGlobal("FluxConfig", {debug: true, scrollBehavior: "smooth"});
+ await import("../src/main.es6");
+ let {RuntimeConfig} = await import("../src/RuntimeConfig.es6");
+ let {Flux} = await import("../src/Flux.es6");
+ expect(RuntimeConfig.debug).toBe(true);
+ expect(RuntimeConfig.scrollToTopBehavior).toBe("smooth");
+ expect(Flux).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/CssProperties/Combinations.test.js b/test/CssProperties/Combinations.test.js
new file mode 100644
index 0000000..25095d1
--- /dev/null
+++ b/test/CssProperties/Combinations.test.js
@@ -0,0 +1,58 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {createRuntime, property} from "./Support.js";
+import {Flux} from "../../src/Flux.es6";
+import {SortableItems} from "../../src/DragOrder/SortableItems.es6";
+import {Style} from "../../src/Style.es6";
+
+let context;
+let flux;
+afterEach(() => {
+ context?.runtime.dispose();
+ flux?.cssPropertyRuntime?.dispose();
+ flux?.liveHandler?.stop();
+ if(flux) document.removeEventListener("flux:after-render", flux.initRenderedCssProperties);
+ vi.restoreAllMocks(); vi.unstubAllGlobals();
+});
+function start(html) {
+ context = createRuntime(html);
+ context.runtime.dispose();
+ flux = new Flux();
+ return context;
+}
+function response(html) { return new DOMParser().parseFromString(html, "text/html"); }
+
+describe("CSS sources alongside existing directives", () => {
+ it("registers live updates and CSS sources on the same node", async () => {
+ let {flush} = start('');
+ await flush();
+ expect(flux.updateTargetRegistry.getElements("live-inner")).toContain(document.querySelector("section"));
+ flux.documentUpdater.apply(response(''), ["live-inner"]);
+ await flush();
+ expect(document.querySelector("section").textContent).toBe("New");
+ expect(property("section", "visible")).toBe("1");
+ });
+ it("starts CSS sources first introduced in autocomplete results", async () => {
+ let {flush} = start(' ');
+ expect(flux.cssPropertyRuntime).toBeUndefined();
+ let form = document.querySelector("form");
+ flux.autocompleteHandler.applyResults(form, flux.autocompleteHandler.state.get(form), response(''));
+ await flush();
+ expect(property("section", "visible")).toBe("1");
+ });
+ it("starts CSS sources first introduced by an attribute update", async () => {
+ let {flush} = start(' ');
+ expect(flux.cssPropertyRuntime).toBeUndefined();
+ flux.documentUpdater.apply(response(' '));
+ await flush();
+ expect(property("input", "field-length")).toBe("5");
+ });
+ it("recognises drag items with additional CSS directives", () => {
+ document.body.innerHTML = '';
+ expect(new SortableItems().getSiblings(document.querySelector("ul"))).toHaveLength(1);
+ });
+ it("keeps the autosave hiding rule applicable to combined directives", () => {
+ document.body.innerHTML = 'Save ';
+ let style = new Style(); style.addToDocument();
+ expect(window.getComputedStyle(document.querySelector("button")).display).toBe("none");
+ });
+});
diff --git a/test/CssProperties/Controls.test.js b/test/CssProperties/Controls.test.js
new file mode 100644
index 0000000..5eb02e2
--- /dev/null
+++ b/test/CssProperties/Controls.test.js
@@ -0,0 +1,104 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {createRuntime, property, event} from "./Support.js";
+
+let context;
+afterEach(() => { context?.runtime.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+function start(html) { context = createRuntime(html); return context; }
+
+describe("Control CSS sources", () => {
+ it("normalises a descendant range and follows reset", async () => {
+ let {flush} = start(' ');
+ await flush();
+ expect(property("label", "range")).toBe("0.25");
+ event("input", "input", "80"); await flush();
+ expect(property("label", "range")).toBe("1");
+ document.querySelector("form").reset(); await flush();
+ expect(property("label", "range")).toBe("0.25");
+ });
+ it("uses the first matching control or the bound control itself", async () => {
+ let {flush} = start('
');
+ await flush();
+ expect(property("div", "range")).toBe("0.2");
+ expect(property("#direct", "range")).toBe("0.4");
+ });
+ it("removes a non-numeric selection and exposes a colour", async () => {
+ let {flush} = start('Three None ');
+ await flush();
+ expect(property("select", "select")).toBe("3");
+ expect(property("input", "color")).toBe("#123456");
+ event("select", "change", "none"); await flush();
+ expect(property("select", "select")).toBe("");
+ });
+ it("exposes field budgets, native validation, and independent history flags", async () => {
+ let {flush} = start(' ');
+ await flush();
+ expect(property("label", "field-length")).toBe("3");
+ expect(property("label", "field-remaining")).toBe("7");
+ expect(property("label", "field-filled-scalar")).toBe("0.3");
+ expect(property("label", "field-clean")).toBe("1");
+ event("input", "focusin"); event("input", "input", "123"); await flush();
+ expect(property("label", "field-touched")).toBe("1");
+ expect(property("label", "field-dirty")).toBe("1");
+ expect(property("label", "field-pattern-error")).toBe("1");
+ expect(property("label", "field-invalid")).toBe("1");
+ event("input", "input", "abc"); await flush();
+ expect(property("label", "field-changed")).toBe("0");
+ expect(property("label", "field-dirty")).toBe("1");
+ document.querySelector("form").reset(); await flush();
+ expect(property("label", "field-clean")).toBe("1");
+ expect(property("label", "field-untouched")).toBe("1");
+ });
+ it("honours cancelled reset events", async () => {
+ let {flush} = start(' ');
+ await flush(); event("input", "input", "b"); await flush();
+ document.querySelector("form").addEventListener("reset", event => event.preventDefault());
+ document.querySelector("form").reset(); await flush();
+ expect(property("input", "field-dirty")).toBe("1");
+ expect(property("input", "field-changed")).toBe("1");
+ });
+ it("updates validation counts when fields or constraints change", async () => {
+ let {flush} = start('Save ');
+ await flush();
+ expect(property("form", "form-field-count")).toBe("2");
+ expect(property("form", "form-valid-count")).toBe("1");
+ expect(property("form", "form-invalid-count")).toBe("1");
+ expect(property("form", "form-valid-scalar")).toBe("0.5");
+ document.querySelector("input").remove(); await flush();
+ expect(property("form", "form-all-valid")).toBe("1");
+ });
+ it("counts external form-associated fields and treats an empty form as valid", async () => {
+ let {flush} = start(' ');
+ await flush(); expect(property("form", "form-invalid-count")).toBe("1");
+ event("input", "input", "ready"); await flush();
+ expect(property("form", "form-all-valid")).toBe("1");
+ document.querySelector("input").remove(); await flush();
+ expect(property("form", "form-valid-scalar")).toBe("1");
+ });
+ it("removes budgets when maxlength is removed and binds a replacement input", async () => {
+ let {flush} = start(' ');
+ await flush(); document.querySelector("input").removeAttribute("maxlength"); await flush();
+ expect(property("label", "field-remaining")).toBe("");
+ document.querySelector("label").innerHTML = 'hello '; await flush();
+ expect(property("label", "field-length")).toBe("5");
+ });
+ it("updates both sides of a radio group without marking the unfocussed control touched", async () => {
+ let {flush} = start(' ');
+ await flush();
+ document.querySelector("#second").checked = true;
+ event("#second", "focusin"); event("#second", "change"); await flush();
+ expect(property("#first", "field-changed")).toBe("1");
+ expect(property("#first", "field-dirty")).toBe("1");
+ expect(property("#first", "field-touched")).toBe("0");
+ expect(property("#second", "field-touched")).toBe("1");
+ });
+
+ it("records focus and edits before the first scheduled CSS flush", async () => {
+ let {flush} = start(' ');
+ event("input", "focusin"); event("input", "input", "edited");
+ await flush();
+ expect(property("input", "field-touched")).toBe("1");
+ expect(property("input", "field-dirty")).toBe("1");
+ expect(property("input", "field-changed")).toBe("1");
+ });
+
+});
diff --git a/test/CssProperties/Palette.test.js b/test/CssProperties/Palette.test.js
new file mode 100644
index 0000000..b1cedf9
--- /dev/null
+++ b/test/CssProperties/Palette.test.js
@@ -0,0 +1,78 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {extractPalette} from "../../src/CssProperties/Palette.es6";
+import {createRuntime, property} from "./Support.js";
+
+let context;
+afterEach(() => { context?.runtime.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+function pixels(...colours) { return new Uint8ClampedArray(colours.flatMap(colour => [...colour, 255])); }
+function canvas(data) {
+ let drawing = {drawImage: vi.fn(), getImageData: vi.fn(() => ({data}))};
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(drawing);
+ return drawing;
+}
+function loaded(image) {
+ Object.defineProperties(image, {complete: {value: true}, naturalWidth: {value: 100}});
+}
+
+describe("Palette extraction", () => {
+ it("calculates dominant, accent, extrema, mean, and temperature from one sample", () => {
+ let result = extractPalette(pixels([200, 0, 0], [200, 0, 0], [0, 0, 255], [40, 40, 40], [230, 230, 230]));
+ expect(result[""]).toBe("#c80000"); expect(result.accent).toBe("#0000ff");
+ expect(result.dark).toBe("#0000ff"); expect(result.light).toBe("#e6e6e6");
+ expect(result.average).toBe("#863669"); expect(result.temp).toBeCloseTo((134 - 105) / 255);
+ });
+ it("ignores transparency and has defined fallbacks for monochrome samples", () => {
+ expect(extractPalette(new Uint8ClampedArray([255, 0, 0, 0]))).toBeNull();
+ let result = extractPalette(pixels([0, 0, 0]));
+ for(let name of ["", "accent", "dark", "light", "average"]) expect(result[name]).toBe("#000000");
+ expect(result.temp).toBe(0);
+ });
+ it("samples loaded images once and reuses the result on visibility changes", async () => {
+ context = createRuntime(' ', {observers: true});
+ let drawing = canvas(pixels([200, 0, 0])); loaded(document.querySelector("img"));
+ let {flush, intersections} = context;
+ let figure = document.querySelector("figure");
+ let intersect = visible => intersections[0].emit(figure, {isIntersecting: visible, intersectionRatio: visible ? 1 : 0});
+ intersect(true); await flush(); expect(property("figure", "palette")).toBe("#c80000");
+ intersect(false); await flush(); intersect(true); await flush();
+ expect(drawing.getImageData).toHaveBeenCalledOnce();
+ });
+ it("does not retry unreadable media every frame", async () => {
+ context = createRuntime(' ');
+ loaded(document.querySelector("img"));
+ let drawing = canvas(pixels([0, 0, 0])); drawing.getImageData.mockImplementation(() => { throw new DOMException("Tainted canvas", "SecurityError"); });
+ await context.flush(); context.runtime.refreshAll(); await context.flush();
+ expect(drawing.getImageData).toHaveBeenCalledOnce(); expect(property("img", "palette")).toBe("");
+ });
+ it("discards asynchronous image results after removal", async () => {
+ context = createRuntime(' ');
+ let drawing = canvas(pixels([100, 0, 0])); loaded(document.querySelector("img"));
+ let resolve; vi.stubGlobal("createImageBitmap", vi.fn(() => new Promise(done => { resolve = done; })));
+ await context.flush(); document.querySelector("img").remove(); await context.flush();
+ let bitmap = {close: vi.fn()}; resolve(bitmap); await context.flush();
+ expect(bitmap.close).toHaveBeenCalledOnce(); expect(drawing.drawImage).not.toHaveBeenCalled();
+ });
+ it("caps video sampling and cancels its callback off-screen", async () => {
+ context = createRuntime(' ', {observers: true});
+ let video = document.querySelector("video");
+ Object.defineProperties(video, {readyState: {value: 2}, paused: {value: false}});
+ let next; video.requestVideoFrameCallback = vi.fn(callback => { next = callback; return 1; });
+ video.cancelVideoFrameCallback = vi.fn();
+ let drawing = canvas(pixels([0, 100, 0]));
+ let time = 1000; vi.spyOn(window.performance, "now").mockImplementation(() => time);
+ context.intersections[0].emit(video, {isIntersecting: true, intersectionRatio: 1});
+ await context.flush(); expect(property("video", "palette")).toBe("#006400");
+ time += 100; next(); await context.flush(); expect(drawing.getImageData).toHaveBeenCalledOnce();
+ time += 200; next(); await context.flush(); expect(drawing.getImageData).toHaveBeenCalledTimes(2);
+ context.intersections[0].emit(video, {isIntersecting: false, intersectionRatio: 0}); await context.flush();
+ expect(video.cancelVideoFrameCallback).toHaveBeenCalled();
+ for(let i = 0; i < 100; i++) video.dispatchEvent(new Event("timeupdate"));
+ expect(context.frames.size).toBe(0);
+ expect(drawing.getImageData).toHaveBeenCalledTimes(2);
+ context.intersections[0].emit(video, {isIntersecting: true, intersectionRatio: 1}); await context.flush();
+ video.cancelVideoFrameCallback.mockClear();
+ vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange"));
+ expect(video.cancelVideoFrameCallback).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/CssProperties/Parser.test.js b/test/CssProperties/Parser.test.js
new file mode 100644
index 0000000..4b762c8
--- /dev/null
+++ b/test/CssProperties/Parser.test.js
@@ -0,0 +1,46 @@
+import {describe, it, expect, vi} from "vitest";
+import {DirectiveParser} from "../../src/DirectiveParser.es6";
+import {DirectiveRegistry} from "../../src/DirectiveRegistry.es6";
+import {CSS_SOURCES} from "../../src/CssProperties/SourceRegistry.es6";
+
+describe("Flux directive parsing", () => {
+ it("keeps full CSS selectors inside connections", () => {
+ expect(DirectiveParser.parse(`update-inner flux-size (flux-pointer,flux-size@footer > :is(.a,.b), [data-label="a ) @ b"])`)).toEqual([
+ {names: ["update-inner"], selector: null},
+ {names: ["flux-size"], selector: null},
+ {names: ["flux-pointer", "flux-size"], selector: 'footer > :is(.a,.b), [data-label="a ) @ b"]'},
+ ]);
+ });
+ it("accepts newlines, duplicates, and escaped selector characters", () => {
+ expect(DirectiveParser.parse("flux-size\nflux-size\t(flux-size@#a\\)b)")).toHaveLength(2);
+ });
+ it.each(["(flux-size@)", "(flux-size@div", "flux-size)", '(flux-size@[title="x])', "(flux-size div)"])("rejects malformed syntax: %s", value => {
+ expect(() => DirectiveParser.parse(value)).toThrow(SyntaxError);
+ });
+ it("initialises valid neighbours despite an unknown directive", () => {
+ let update = vi.fn();
+ let css = vi.fn();
+ let registry = new DirectiveRegistry({updateInner: update, cssProperties: css});
+ let element = document.createElement("div");
+ element.dataset.flux = "unknown update-inner flux-size flux-pointer (flux-size@footer)";
+ expect(() => registry.initElement(element)).toThrow("Unknown flux element type: unknown");
+ expect(update).toHaveBeenCalledOnce();
+ expect(css).toHaveBeenCalledOnce();
+ });
+ it("supports auto on forms, buttons, and links with CSS sources", () => {
+ let autoContainer = vi.fn();
+ let autoSubmit = vi.fn();
+ let registry = new DirectiveRegistry({autoContainer, autoSubmit, cssProperties: vi.fn()});
+ for(let tag of ["form", "button", "a"]) {
+ let element = document.createElement(tag);
+ element.dataset.flux = "auto flux-size";
+ registry.initElement(element);
+ }
+ expect(autoContainer).toHaveBeenCalledTimes(2);
+ expect(autoSubmit).toHaveBeenCalledOnce();
+ });
+ it("registers all sixteen CSS sources", () => {
+ expect(Object.keys(CSS_SOURCES)).toHaveLength(16);
+ for(let name of Object.keys(CSS_SOURCES)) expect(DirectiveRegistry.DEFINITIONS[name].handler).toBe("cssProperties");
+ });
+});
diff --git a/test/CssProperties/Runtime.test.js b/test/CssProperties/Runtime.test.js
new file mode 100644
index 0000000..8088983
--- /dev/null
+++ b/test/CssProperties/Runtime.test.js
@@ -0,0 +1,211 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {createRuntime, property, event} from "./Support.js";
+import {DocumentUpdater} from "../../src/DocumentUpdater.es6";
+import {UpdateTargetRegistry} from "../../src/UpdateTargetRegistry.es6";
+import {FocusStateManager} from "../../src/FocusStateManager.es6";
+import {Flux} from "../../src/Flux.es6";
+
+let context;
+afterEach(() => { context?.runtime.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+function start(html, options) { context = createRuntime(html, options); return context; }
+function rectangle(element, rect = {left: 20, top: 40, width: 200, height: 100}) {
+ return vi.spyOn(element, "getBoundingClientRect").mockReturnValue({...rect, right: rect.left + rect.width, bottom: rect.top + rect.height});
+}
+function intersect(observer, element, visible) { observer.emit(element, {isIntersecting: visible, intersectionRatio: visible ? 0.5 : 0}); }
+
+describe("CSS runtime geometry and connections", () => {
+ it("updates local and global coordinates on contact without a preceding move", async () => {
+ let {flush, runtime} = start('
');
+ let element = document.querySelector("div");
+ rectangle(element);
+ await flush();
+ for(let [x, y, expectedX, expectedY] of [[70, 65, "0.25", "0.25"], [170, 115, "0.75", "0.75"]]) {
+ element.dispatchEvent(new MouseEvent("pointerdown", {bubbles: true, clientX: x, clientY: y}));
+ await flush();
+ expect(property("div", "pointer-x")).toBe(expectedX);
+ expect(property("div", "pointer-y")).toBe(expectedY);
+ expect(property("div", "pointer-global-x-px")).toBe(String(x));
+ expect(property("div", "pointer-global-y-px")).toBe(String(y));
+ }
+ runtime.dispose();
+ window.dispatchEvent(new MouseEvent("pointerdown", {clientX: 10, clientY: 20}));
+ expect(runtime.pointer).toEqual({x: 170, y: 115});
+ });
+ it("shares observers, batches pointer reads, and idles after writing", async () => {
+ let {flush, intersections, resizes, frames} = start('
', {observers: true});
+ let element = document.querySelector("div");
+ let measure = rectangle(element);
+ expect(intersections).toHaveLength(1); expect(resizes).toHaveLength(1);
+ intersect(intersections[0], element, true);
+ await flush(); measure.mockClear();
+ for(let x = 21; x <= 120; x++) window.dispatchEvent(new MouseEvent("pointermove", {clientX: x, clientY: 65}));
+ expect(measure).not.toHaveBeenCalled();
+ await flush();
+ expect(measure).toHaveBeenCalledOnce();
+ expect(property("div", "pointer-x")).toBe("0.5");
+ expect(property("div", "pointer-y")).toBe("0.25");
+ expect(property("div", "pointer-x-px")).toBe("100");
+ expect(property("div", "pointer-y-px")).toBe("25");
+ expect(property("div", "size-x")).toBe("200");
+ expect(frames.size).toBe(0);
+ intersect(intersections[0], element, false); await flush(); measure.mockClear();
+ window.dispatchEvent(new MouseEvent("pointermove", {clientX: 30})); await flush();
+ expect(measure).not.toHaveBeenCalled();
+ expect(property("div", "visible")).toBe("0");
+ expect(property("div", "first-visible")).toBe("1");
+ });
+ it("measures border-box sizes in physical axes", async () => {
+ let {flush, intersections, resizes} = start('
', {observers: true});
+ let element = document.querySelector("div");
+ intersect(intersections[0], element, true);
+ resizes[0].emit(element, {borderBoxSize: [{inlineSize: 180.25, blockSize: 75.5}]});
+ await flush();
+ expect(property("div", "size-x")).toBe("180.25");
+ expect(property("div", "size-y")).toBe("75.5");
+ });
+ it("uses viewport coordinates for global pointers", async () => {
+ let {flush} = start('
');
+ window.dispatchEvent(new MouseEvent("pointermove", {clientX: window.innerWidth / 2, clientY: window.innerHeight / 4}));
+ await flush();
+ expect(property("div", "pointer-global-x")).toBe("0.5");
+ expect(property("div", "pointer-global-y")).toBe("0.25");
+ expect(property("div", "pointer-global-x-px")).toBe(String(window.innerWidth / 2));
+ expect(property("div", "pointer-global-y-px")).toBe(String(window.innerHeight / 4));
+ });
+ it("retains fractional pixels and remeasures local coordinates after scrolling", async () => {
+ let {flush} = start('
');
+ let measure = rectangle(document.querySelector("div"), {left: 20.25, top: 40.75, width: 200, height: 100});
+ window.dispatchEvent(new MouseEvent("pointermove", {clientX: 120, clientY: 65}));
+ await flush();
+ expect(property("div", "pointer-x-px")).toBe("99.75");
+ expect(property("div", "pointer-y-px")).toBe("24.25");
+ measure.mockReturnValue({left: 10.25, top: 30.75, width: 200, height: 100});
+ document.dispatchEvent(new Event("scroll"));
+ await flush();
+ expect(property("div", "pointer-x-px")).toBe("109.75");
+ expect(property("div", "pointer-y-px")).toBe("34.25");
+ expect(property("div", "pointer-global-x-px")).toBe("120");
+ expect(property("div", "pointer-global-y-px")).toBe("65");
+ });
+ it("clamps pixel coordinates to the local and viewport edges, including empty boxes", async () => {
+ let {flush} = start('
');
+ let measure = rectangle(document.querySelector("div"));
+ await flush();
+ expect(property("div", "pointer-x-px")).toBe("0");
+ expect(property("div", "pointer-global-y-px")).toBe("0");
+ window.dispatchEvent(new MouseEvent("pointermove", {clientX: -10, clientY: window.innerHeight + 100}));
+ await flush();
+ expect(property("div", "pointer-x-px")).toBe("0");
+ expect(property("div", "pointer-y-px")).toBe("100");
+ expect(property("div", "pointer-global-x-px")).toBe("0");
+ expect(property("div", "pointer-global-x-raw-px")).toBe("-10");
+ expect(property("div", "pointer-global-y-raw-px")).toBe(String(window.innerHeight + 100));
+ expect(Number(property("div", "pointer-x-raw-px"))).toBeLessThan(0);
+ expect(Number(property("div", "pointer-y-raw-px"))).toBeGreaterThan(100);
+ expect(property("div", "pointer-global-y-px")).toBe(String(window.innerHeight));
+ measure.mockReturnValue({left: 0, top: 0, width: 0, height: 0});
+ window.dispatchEvent(new Event("resize"));
+ await flush();
+ expect(property("div", "pointer-x-px")).toBe("0");
+ expect(property("div", "pointer-y-px")).toBe("0");
+ expect(property("div", "pointer-y")).toBe("0");
+ });
+ it("keeps an off-screen source active for a visible remote destination", async () => {
+ let {flush, intersections} = start('
', {observers: true});
+ rectangle(document.querySelector("div"));
+ intersect(intersections[0], document.querySelector(".preview"), true);
+ window.dispatchEvent(new MouseEvent("pointermove", {clientX: 120, clientY: 90}));
+ await flush();
+ expect(property("div", "pointer-x")).toBe("0.5");
+ expect(property(".preview", "pointer-x")).toBe("0.5");
+ expect(property(".summary", "pointer-x")).toBe("0.5");
+ expect(property(".preview", "pointer-x-px")).toBe("100");
+ expect(property(".summary", "pointer-y-px")).toBe("50");
+ });
+ it("reconnects after destination replacement and restores author properties on removal", async () => {
+ let {flush} = start(' ');
+ await flush(); expect(property("footer", "range")).toBe("0.4");
+ document.querySelector("footer").outerHTML = '';
+ await flush(); expect(property("footer", "range")).toBe("0.4");
+ document.querySelector("input").remove(); await flush();
+ expect(property("footer", "range")).toBe(".9");
+ });
+ it("updates selector matches when arbitrary attributes change", async () => {
+ let {flush} = start(' ');
+ await flush(); expect(property("footer", "range")).toBe("");
+ document.querySelector("footer").dataset.target = "active"; await flush();
+ expect(property("footer", "range")).toBe("0.25");
+ });
+ it("reports conflicting owners and promotes the next owner on removal", async () => {
+ let {flush, logger} = start(' ');
+ await flush(); expect(property("footer", "range")).toBe("0.2"); expect(logger.warn).toHaveBeenCalledOnce();
+ event("#b", "input", "90"); await flush(); expect(property("footer", "range")).toBe("0.2");
+ document.querySelector("#a").remove(); await flush(); expect(property("footer", "range")).toBe("0.9");
+ });
+ it("does not let an invalid selector disable valid sources", async () => {
+ let {flush, logger} = start(' ');
+ await flush(); expect(property("input", "range")).toBe("0.3"); expect(logger.error).toHaveBeenCalled();
+ });
+ it("pauses writes in a hidden tab and refreshes on return", async () => {
+ let {flush, frames} = start(' ');
+ await flush(); vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange")); event("input", "input", "80");
+ expect(frames.size).toBe(0); expect(property("input", "range")).toBe("0.2");
+ vi.spyOn(document, "hidden", "get").mockReturnValue(false); document.dispatchEvent(new Event("visibilitychange"));
+ await flush(); expect(property("input", "range")).toBe("0.8");
+ });
+ it("remeasures truncated text when content changes in a fixed-size box", async () => {
+ let {flush} = start('Short
');
+ let element = document.querySelector("p");
+ Object.defineProperties(element, {clientWidth: {value: 100}, scrollWidth: {get: () => element.textContent.length * 10}, clientHeight: {value: 20}, scrollHeight: {value: 20}});
+ await flush(); expect(property("p", "truncated")).toBe("0");
+ element.textContent = "This is much longer text"; await flush();
+ expect(property("p", "truncated-x")).toBe("1"); expect(property("p", "truncated-y")).toBe("0");
+ });
+});
+
+describe("CSS source lifecycle through Flux rendering", () => {
+ function updater(element, type) {
+ let registry = new UpdateTargetRegistry(); registry.add(element, type);
+ return new DocumentUpdater(registry, new FocusStateManager());
+ }
+ function response(html) { return new DOMParser().parseFromString(html, "text/html"); }
+ it.each(["outer", "inner"])("preserves field history after %s updates", async type => {
+ let {flush} = start('');
+ await flush(); event("input", "focusin"); event("input", "input", "edited"); await flush();
+ updater(document.querySelector("section"), type).apply(response(''));
+ await flush();
+ expect(property("label", "field-dirty")).toBe("1"); expect(property("label", "field-touched")).toBe("1"); expect(property("label", "field-changed")).toBe("1");
+ event("input", "input", "original"); await flush(); expect(property("label", "field-changed")).toBe("0");
+ });
+ it("starts new field history on link navigation", async () => {
+ let {flush} = start('');
+ await flush(); event("input", "input", "b"); await flush();
+ updater(document.querySelector("section"), "outer").apply(response(''), undefined, undefined, null, true);
+ await flush(); expect(property("input", "field-clean")).toBe("1"); expect(property("input", "field-unchanged")).toBe("1");
+ });
+ it("reapplies CSS values stripped by an attribute update", async () => {
+ let {flush} = start(' ');
+ await flush();
+ updater(document.querySelector("input"), "attributes").apply(response(' '));
+ await flush(); expect(property("input", "range")).toBe("0.5");
+ });
+ it("preserves history on a DOM move and releases all subscriptions when removed", async () => {
+ let {flush, runtime, intersections, resizes} = start('
', {observers: true});
+ await flush(); event("input", "input", "b"); await flush();
+ document.querySelector("aside").append(document.querySelector("input")); await flush();
+ expect(property("input", "field-dirty")).toBe("1");
+ document.querySelector("input").remove(); await flush();
+ expect(runtime.bindings.size).toBe(0); expect(intersections[0].elements.size).toBe(0); expect(resizes[0].elements.size).toBe(0);
+ });
+ it("initialises mixed directives through Flux without duplicate CSS runtimes", async () => {
+ let {runtime, flush} = start(' ');
+ runtime.dispose();
+ let flux = new Flux();
+ context.runtime = flux.cssPropertyRuntime;
+ await flush();
+ expect(property("input", "field-length")).toBe("1");
+ expect(flux.updateTargetRegistry.getElements("outer")).toContain(document.querySelector("form"));
+ expect(flux.cssPropertyRuntime.bindings.size).toBe(2);
+ });
+});
diff --git a/test/CssProperties/Scroll.test.js b/test/CssProperties/Scroll.test.js
new file mode 100644
index 0000000..8503baf
--- /dev/null
+++ b/test/CssProperties/Scroll.test.js
@@ -0,0 +1,138 @@
+import {afterEach, describe, expect, it, vi} from "vitest";
+import {createRuntime, property} from "./Support.js";
+
+let context;
+afterEach(() => {
+ context?.runtime.dispose();
+ document.body.removeAttribute("data-flux");
+ document.documentElement.removeAttribute("data-flux");
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+function start(html, options) { return context = createRuntime(html, options); }
+function dimensions(element, values) {
+ for(let [name, value] of Object.entries(values)) vi.spyOn(element, name, "get").mockReturnValue(value);
+}
+function rect(element, values) {
+ return vi.spyOn(element, "getBoundingClientRect").mockReturnValue({left: 0, top: 0, width: 100, height: 100, ...values});
+}
+function scroll(element) { element.dispatchEvent(new Event("scroll")); }
+
+describe("scroll CSS sources", () => {
+ it("reports both axes, fractional pixels and clamped scalars from nested scroll events", async () => {
+ let {flush, frames} = start('
', {observers: true});
+ let element = document.querySelector("div");
+ dimensions(element, {clientWidth: 200, scrollWidth: 600, clientHeight: 100, scrollHeight: 500});
+ element.scrollLeft = 100.25; element.scrollTop = 200;
+ await flush();
+ expect(property("div", "scroll-x")).toBe("0.2506");
+ expect(property("div", "scroll-x-px")).toBe("100.25");
+ expect(property("div", "scroll-y")).toBe("0.5");
+ element.scrollTop = 500;
+ scroll(element); scroll(element);
+ expect(frames.size).toBe(1);
+ await flush(); expect(property("div", "scroll-y")).toBe("1");
+ element.scrollTop = -10; scroll(element);
+ await flush(); expect(property("div", "scroll-y")).toBe("0");
+ expect(property("div", "scroll-y-px")).toBe("-10");
+ });
+ it("uses zero for an axis with no scroll range and handles RTL offsets", async () => {
+ let {flush} = start('
');
+ let element = document.querySelector("div");
+ dimensions(element, {clientWidth: 200, scrollWidth: 600, clientHeight: 100, scrollHeight: 100});
+ element.scrollLeft = -200;
+ await flush();
+ expect(property("div", "scroll-x")).toBe("0.5");
+ expect(property("div", "scroll-x-px")).toBe("-200");
+ expect(property("div", "scroll-y")).toBe("0");
+ });
+ it("normalises reversed vertical scrolling while retaining its signed pixels", async () => {
+ let {flush} = start('
');
+ let element = document.querySelector("div");
+ dimensions(element, {clientHeight: 200, scrollHeight: 600});
+ element.scrollTop = -100;
+ await flush();
+ expect(property("div", "scroll-y")).toBe("0.25");
+ expect(property("div", "scroll-y-px")).toBe("-100");
+ });
+ it.each(["body", "html"])("measures document scrolling when declared on %s", async selector => {
+ let {flush, runtime} = start('');
+ document.querySelector(selector).dataset.flux = "flux-scroll";
+ let page = document.documentElement;
+ dimensions(page, {clientHeight: 500, scrollHeight: 1500});
+ page.scrollTop = 250;
+ runtime.synchronise(); await flush();
+ expect(property(selector, "scroll-y")).toBe("0.25");
+ expect(property(selector, "scroll-y-px")).toBe("250");
+ page.scrollTop = 1000; scroll(document); await flush();
+ expect(property(selector, "scroll-y")).toBe("1");
+ page.scrollTop = 0;
+ });
+ it("tracks all passage boundaries outside visibility, accounting for container borders", async () => {
+ let {flush} = start('', {observers: true});
+ let container = document.querySelector("section");
+ let element = document.querySelector("div");
+ dimensions(container, {clientHeight: 200, clientTop: 5});
+ rect(container, {top: 40});
+ let box = rect(element, {top: 345, height: 100});
+ for(let [top, expected, midway] of [[345, "-0.3333", "-0.6667"], [245, "0", "0"], [170, "0.25", "0.5"], [95, "0.5", "1"], [20, "0.75", "0.5"], [-55, "1", "0"], [-155, "1.3333", "-0.6667"]]) {
+ box.mockReturnValue({left: 0, top, width: 100, height: 100});
+ scroll(container); await flush();
+ expect(property("div", "scroll-progress-y")).toBe(expected);
+ expect(property("div", "scroll-midway-y")).toBe(midway);
+ expect(Number(property("div", "scroll-progress-y-inverse"))).toBeCloseTo(1 - Number(expected), 4);
+ }
+ });
+ it("uses the nearest scrollport per axis and the viewport as fallback", async () => {
+ let {flush} = start('');
+ let outer = document.querySelector("section"), inner = document.querySelector("article");
+ dimensions(outer, {clientWidth: 300, clientLeft: 2}); rect(outer, {left: 30});
+ dimensions(inner, {clientHeight: 200, clientTop: 5}); rect(inner, {top: 40});
+ let element = document.querySelector("div"); rect(element, {left: 132, top: 95});
+ await flush();
+ expect(property("div", "scroll-progress-x")).toBe("0.5");
+ expect(property("div", "scroll-progress-x-inverse")).toBe("0.5");
+ expect(property("div", "scroll-midway-x")).toBe("1");
+ expect(property("div", "scroll-progress-y")).toBe("0.5");
+ document.body.append(element);
+ dimensions(document.documentElement, {clientWidth: 364, clientHeight: 290});
+ await flush();
+ expect(property("div", "scroll-progress-x")).toBe("0.5");
+ expect(property("div", "scroll-progress-x-inverse")).toBe("0.5");
+ expect(property("div", "scroll-midway-x")).toBe("1");
+ expect(property("div", "scroll-progress-y")).toBe("0.5");
+ });
+ it("handles elements taller than the viewport and a zero-length passage", async () => {
+ let {flush, frames} = start('
');
+ let element = document.querySelector("div");
+ dimensions(document.documentElement, {clientHeight: 200});
+ let box = rect(element, {top: -200, height: 600});
+ await flush(); expect(property("div", "scroll-progress-y")).toBe("0.5");
+ await flush(); expect(frames.size).toBe(0);
+ dimensions(document.documentElement, {clientHeight: 0});
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(0);
+ box.mockReturnValue({left: 0, top: 0, width: 0, height: 0});
+ window.dispatchEvent(new Event("resize")); await flush();
+ expect(property("div", "scroll-progress-y")).toBe("0");
+ expect(property("div", "scroll-progress-y-inverse")).toBe("1");
+ expect(property("div", "scroll-midway-y")).toBe("0");
+ });
+ it("remeasures on content resize, window resize and DOM updates, then releases observers", async () => {
+ let {flush, runtime, resizes} = start(' ', {observers: true});
+ let container = document.querySelector("section"), content = document.querySelector("article");
+ dimensions(container, {clientHeight: 100});
+ let height = vi.spyOn(container, "scrollHeight", "get").mockReturnValue(300);
+ container.scrollTop = 100;
+ await flush(); expect(property("output", "scroll-y")).toBe("0.5");
+ height.mockReturnValue(500); resizes[0].emit(content, {});
+ await flush(); expect(property("output", "scroll-y")).toBe("0.25");
+ height.mockReturnValue(200); window.dispatchEvent(new Event("resize"));
+ await flush(); expect(property("output", "scroll-y")).toBe("1");
+ height.mockReturnValue(300); content.textContent = "More content";
+ await flush(); expect(property("output", "scroll-y")).toBe("0.5");
+ container.remove(); await flush();
+ expect(resizes[0].elements.size).toBe(0);
+ expect(property("output", "scroll-y")).toBe("");
+ expect(runtime.bindings.size).toBe(0);
+ });
+});
diff --git a/test/CssProperties/Support.js b/test/CssProperties/Support.js
new file mode 100644
index 0000000..82df7d6
--- /dev/null
+++ b/test/CssProperties/Support.js
@@ -0,0 +1,45 @@
+import {vi} from "vitest";
+import {CssPropertyRuntime} from "../../src/CssProperties/Runtime.es6";
+
+export function createRuntime(html, {observers = false} = {}) {
+ document.body.innerHTML = html;
+ let frames = new Map();
+ let nextFrame = 0;
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation(callback => { frames.set(++nextFrame, callback); return nextFrame; });
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation(id => frames.delete(id));
+ vi.spyOn(document, "hidden", "get").mockReturnValue(false);
+ let intersections = [];
+ let resizes = [];
+ function observerClass(collection) {
+ return class {
+ constructor(callback) { this.callback = callback; this.elements = new Set(); collection.push(this); }
+ observe(element) { this.elements.add(element); }
+ unobserve(element) { this.elements.delete(element); }
+ disconnect() { this.elements.clear(); }
+ emit(element, values) { this.callback([{target: element, ...values}]); }
+ };
+ }
+ vi.stubGlobal("IntersectionObserver", observers ? observerClass(intersections) : undefined);
+ vi.stubGlobal("ResizeObserver", observers ? observerClass(resizes) : undefined);
+ let logger = {error: vi.fn(), warn: vi.fn()};
+ let runtime = new CssPropertyRuntime(document, logger);
+ runtime.synchronise();
+ let flush = async () => {
+ await Promise.resolve();
+ let callbacks = [...frames.values()];
+ frames.clear();
+ for(let callback of callbacks) callback(1000);
+ await Promise.resolve();
+ };
+ return {runtime, frames, flush, intersections, resizes, logger};
+}
+
+export function property(selector, name) {
+ return document.querySelector(selector).style.getPropertyValue(`--flux-${name}`);
+}
+
+export function event(selector, type, value) {
+ let element = document.querySelector(selector);
+ if(value !== undefined) element.value = value;
+ element.dispatchEvent(new Event(type, {bubbles: true, cancelable: true}));
+}
diff --git a/test/CssProperties/Time.test.js b/test/CssProperties/Time.test.js
new file mode 100644
index 0000000..4a7263c
--- /dev/null
+++ b/test/CssProperties/Time.test.js
@@ -0,0 +1,135 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {execFileSync} from "node:child_process";
+import {readFileSync} from "node:fs";
+import {calendarValues, timeValues} from "../../src/CssProperties/CalendarValues.es6";
+import {createRuntime, property} from "./Support.js";
+
+let context;
+afterEach(() => {
+ context?.runtime.dispose();
+ context = null;
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+function start(html, date, options) {
+ vi.useFakeTimers({toFake: ["Date", "setTimeout", "clearTimeout"]});
+ vi.setSystemTime(date);
+ context = createRuntime(html, options);
+ return context;
+}
+
+describe("Time and calendar values", () => {
+ it("places analogue hands between whole hours and minutes", () => {
+ let values = timeValues(new Date(2026, 8, 7, 15, 30, 45));
+ expect(values.hour).toBe(3);
+ expect(values.minute).toBe(30);
+ expect(values.second).toBe(45);
+ expect(values["second-scalar"]).toBe(0.75);
+ expect(values["minute-scalar"]).toBe(30.75 / 60);
+ expect(values["hour-scalar"] * 360).toBeCloseTo(105.375);
+ });
+ it.each([0, 12])("uses zero for hour %i on the twelve-hour dial", hour => {
+ expect(timeValues(new Date(2026, 0, 1, hour)).hour).toBe(0);
+ });
+ it("calculates leap-year, month, week, and day progress", () => {
+ let values = calendarValues(new Date(2024, 1, 29, 12));
+ expect(values).toMatchObject({year: 2024, month: 2, day: 29, weekday: 4});
+ expect(values["year-scalar"]).toBe(59.5 / 366);
+ expect(values["month-scalar"]).toBe(28.5 / 29);
+ expect(values["week-scalar"]).toBe(3.5 / 7);
+ expect(values["day-scalar"]).toBe(0.5);
+ });
+ it("starts weeks on Monday and resets calendar progress at New Year", () => {
+ expect(calendarValues(new Date(2026, 8, 6)).weekday).toBe(7);
+ expect(calendarValues(new Date(2026, 8, 7))["week-scalar"]).toBe(0);
+ expect(calendarValues(new Date(2027, 0, 1))).toMatchObject({year: 2027, month: 1, day: 1, "year-scalar": 0, "month-scalar": 0, "day-scalar": 0});
+ expect(calendarValues(new Date(2100, 1, 28, 12))["month-scalar"]).toBe(27.5 / 28);
+ });
+ it.each(["Europe/London", "America/New_York"])("counts calendar days across daylight-saving changes in %s", timezone => {
+ let source = readFileSync("src/CssProperties/CalendarValues.es6", "utf8");
+ let script = `${source}\nconsole.log(JSON.stringify([calendarValues(new Date(2024, 2, 31, 12)), calendarValues(new Date(2024, 10, 3, 12))]));`;
+ let results = JSON.parse(execFileSync(process.execPath, ["--input-type=module", "-e", script], {env: {...process.env, TZ: timezone}, encoding: "utf8"}));
+ expect(results[0]["day-scalar"]).toBe(0.5);
+ expect(results[0]["month-scalar"]).toBe(30.5 / 31);
+ expect(results[1]["day-scalar"]).toBe(0.5);
+ expect(results[1]["month-scalar"]).toBe(2.5 / 30);
+ });
+});
+
+describe("Time and date CSS sources", () => {
+ it("shares a second-aligned timer and crosses midnight using the actual clock", async () => {
+ let {runtime, flush} = start('
', new Date(2026, 11, 31, 23, 59, 59, 750));
+ let schedule = vi.spyOn(window, "setTimeout");
+ await flush();
+ expect(runtime.clock.subscribers.size).toBe(3);
+ expect(schedule).toHaveBeenCalledTimes(1);
+ expect(schedule).toHaveBeenLastCalledWith(expect.any(Function), 250);
+ expect(property("div", "time-second")).toBe("59");
+ expect(property("div", "date-year")).toBe("2026");
+ vi.advanceTimersByTime(250); await flush();
+ expect(property("div", "time-hour")).toBe("0");
+ expect(property("aside", "time-second")).toBe("0");
+ expect(property("div", "date-year")).toBe("2027");
+ expect(property("div", "date-month")).toBe("1");
+ expect(property("div", "day-scalar")).toBe("0");
+ expect(schedule).toHaveBeenLastCalledWith(expect.any(Function), 1000);
+ vi.setSystemTime(new Date(2027, 0, 1, 9, 42, 10));
+ vi.advanceTimersByTime(1000); await flush();
+ expect(property("div", "time-minute")).toBe("42");
+ });
+ it("emits quoted, localised names and follows changes to the inherited language", async () => {
+ let {flush} = start('', new Date(2026, 8, 7, 12));
+ await flush();
+ expect(property("div", "date-month-name")).toBe('"September"');
+ expect(property("div", "date-month-name-short")).toBe('"Sept"');
+ expect(property("div", "date-day-name")).toBe('"Monday"');
+ expect(property("div", "date-day-name-short")).toBe('"Mon"');
+ expect(property("div", "date-weekday")).toBe("1");
+ expect(property("div", "day-scalar")).toBe("0.5");
+ document.querySelector("section").lang = "fr-FR"; await flush();
+ expect(property("div", "date-month-name")).toBe('"septembre"');
+ expect(property("div", "date-day-name")).toBe('"lundi"');
+ document.querySelector("section").lang = "not_a_locale"; await flush();
+ expect(context.logger.error).not.toHaveBeenCalled();
+ });
+ it("pauses off-screen, follows remote destinations, and cancels the last timer on removal", async () => {
+ let {runtime, intersections, flush} = start('
', new Date(2026, 8, 7, 12), {observers: true});
+ await flush(); expect(runtime.clock.timer).toBeNull();
+ let footer = document.querySelector("footer");
+ intersections[0].emit(footer, {isIntersecting: true, intersectionRatio: 1}); await flush();
+ expect(property("footer", "time-hour")).toBe("0");
+ expect(property("footer", "day-scalar")).toBe("0.5");
+ expect(runtime.clock.subscribers.size).toBe(2);
+ intersections[0].emit(footer, {isIntersecting: false, intersectionRatio: 0}); await flush();
+ expect(runtime.clock.timer).toBeNull();
+ vi.setSystemTime(new Date(2026, 8, 7, 13, 20));
+ intersections[0].emit(footer, {isIntersecting: true, intersectionRatio: 1}); await flush();
+ expect(property("footer", "time-minute")).toBe("20");
+ document.querySelector("div").remove(); await flush();
+ expect(runtime.clock.subscribers.size).toBe(0);
+ expect(runtime.clock.timer).toBeNull();
+ expect(property("footer", "day-scalar")).toBe("");
+ });
+ it("cancels timing immediately when the tab is hidden and resumes with current values", async () => {
+ let {runtime, flush} = start('
', new Date(2026, 8, 7, 12));
+ await flush();
+ vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange"));
+ expect(runtime.clock.timer).toBeNull();
+ vi.setSystemTime(new Date(2026, 8, 7, 15, 10));
+ vi.spyOn(document, "hidden", "get").mockReturnValue(false);
+ document.dispatchEvent(new Event("visibilitychange")); await flush();
+ expect(property("div", "time-hour")).toBe("3");
+ expect(property("div", "time-minute")).toBe("10");
+ expect(runtime.clock.subscribers.size).toBe(1);
+ });
+ it("rebinds a replaced clock without keeping its old subscription", async () => {
+ let {runtime, flush} = start('
', new Date(2026, 8, 7, 12));
+ await flush();
+ document.querySelector("div").outerHTML = '
';
+ await flush();
+ expect(runtime.clock.subscribers.size).toBe(2);
+ expect(property("#clock", "date-day")).toBe("7");
+ });
+});
diff --git a/test/CssProperties/Writer.test.js b/test/CssProperties/Writer.test.js
new file mode 100644
index 0000000..105f48b
--- /dev/null
+++ b/test/CssProperties/Writer.test.js
@@ -0,0 +1,49 @@
+import {afterEach, describe, it, expect, vi} from "vitest";
+import {createRuntime} from "./Support.js";
+
+let context;
+afterEach(() => { context?.runtime.dispose(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+describe("Batched CSS writing", () => {
+ it("writes a property once per frame and skips unchanged values without waking another frame", async () => {
+ context = createRuntime('
');
+ let {runtime, flush, frames} = context;
+ let element = document.querySelector("div"), owner = {};
+ let writes = vi.spyOn(element.style, "setProperty");
+ for(let value = 0; value < 100; value++) runtime.writer.set(owner, element, "--example", value);
+ expect(writes).not.toHaveBeenCalled(); await flush();
+ expect(writes).toHaveBeenCalledOnce(); expect(element.style.getPropertyValue("--example")).toBe("99");
+ runtime.writer.set(owner, element, "--example", 99);
+ expect(frames.size).toBe(0);
+ });
+ it("restores original values when ownership changes before a pending flush", async () => {
+ context = createRuntime('
');
+ let {runtime, flush} = context;
+ let element = document.querySelector("div"), first = {}, second = {};
+ runtime.writer.set(first, element, "--example", "first"); await flush();
+ runtime.writer.release(first); runtime.writer.set(second, element, "--example", "second"); await flush();
+ expect(element.style.getPropertyValue("--example")).toBe("second");
+ runtime.writer.release(second); await flush();
+ expect(element.style.getPropertyValue("--example")).toBe("original");
+ });
+ it("cancels pending writes when a source is removed before its first flush", async () => {
+ context = createRuntime(' ');
+ let element = document.querySelector("input"); element.remove();
+ await context.flush();
+ expect(element.style.getPropertyValue("--flux-range")).toBe("");
+ expect(context.runtime.writer.targets.size).toBe(0);
+ });
+ it("releases removed elements without waiting for a hidden tab to paint", async () => {
+ context = createRuntime(' ');
+ await context.flush();
+ let element = document.querySelector("input");
+ vi.spyOn(document, "hidden", "get").mockReturnValue(true);
+ document.dispatchEvent(new Event("visibilitychange"));
+ element.remove(); await Promise.resolve();
+ expect(context.frames.size).toBe(0);
+ expect(context.runtime.writer.targets.size).toBe(0);
+ expect(context.runtime.writer.pending.size).toBe(0);
+ expect(context.runtime.writer.ownership.size).toBe(0);
+ expect(element.style.getPropertyValue("--flux-range")).toBe("original");
+ });
+
+});
diff --git a/test/DialogHandler.test.js b/test/DialogHandler.test.js
new file mode 100644
index 0000000..2027813
--- /dev/null
+++ b/test/DialogHandler.test.js
@@ -0,0 +1,63 @@
+import {afterEach, describe, expect, it, vi} from "vitest";
+import {DialogHandler} from "../src/DialogHandler.es6";
+
+afterEach(() => { document.body.innerHTML = ""; });
+
+function dialog() {
+ let element = document.createElement("dialog");
+ element.setAttribute("open", "");
+ element.matches = vi.fn(() => false);
+ element.showModal = vi.fn(() => { element.open = true; });
+ return element;
+}
+
+describe("Server-rendered modal dialogs", () => {
+ it("waits for insertion before promoting the open fallback to a modal", async () => {
+ let handler = new DialogHandler();
+ let element = dialog();
+ element.showModal.mockImplementation(() => {
+ expect(element.isConnected).toBe(true);
+ expect(element.open).toBe(false);
+ element.open = true;
+ });
+ handler.initModal(element);
+ expect(element.showModal).not.toHaveBeenCalled();
+ document.body.append(element);
+ await Promise.resolve();
+ expect(element.showModal).toHaveBeenCalledOnce();
+ });
+ it("does not reopen a dismissed dialog when initialised again", async () => {
+ let handler = new DialogHandler(); let element = dialog();
+ document.body.append(element);
+ handler.initModal(element); handler.initModal(element);
+ await Promise.resolve();
+ element.open = false;
+ handler.initModal(element); await Promise.resolve();
+ expect(element.showModal).toHaveBeenCalledOnce();
+ expect(element.open).toBe(false);
+ });
+ it("ignores nodes discarded before insertion and opens a fresh replacement", async () => {
+ let handler = new DialogHandler(); let discarded = dialog(); let replacement = dialog();
+ handler.initModal(discarded); handler.initModal(replacement);
+ document.body.append(replacement); await Promise.resolve();
+ expect(discarded.showModal).not.toHaveBeenCalled();
+ expect(replacement.showModal).toHaveBeenCalledOnce();
+ });
+ it("returns focus to the initiating control after the dialog closes", async () => {
+ let trigger = document.createElement("button");
+ document.body.append(trigger); trigger.focus();
+ let element = dialog(); new DialogHandler().initModal(element);
+ document.body.append(element); await Promise.resolve();
+ trigger.blur(); element.open = false;
+ element.dispatchEvent(new Event("close"));
+ expect(document.activeElement).toBe(trigger);
+ });
+ it("retains the open fallback if showModal is unavailable", async () => {
+ let element = document.createElement("dialog"); element.open = true;
+ document.body.append(element); new DialogHandler().initModal(element);
+ await Promise.resolve(); expect(element.open).toBe(true);
+ });
+ it("rejects non-dialog elements", () => {
+ expect(() => new DialogHandler().initModal(document.createElement("div"))).toThrow("requires a dialog");
+ });
+});
diff --git a/test/Flux.test.js b/test/Flux.test.js
index 681e64f..8a074da 100644
--- a/test/Flux.test.js
+++ b/test/Flux.test.js
@@ -389,8 +389,10 @@ describe("FocusStateManager", () => {
let matched = focusStateManager.capturePendingActiveElement(newDocument);
document.body.replaceWith(newDocument.body);
+ let restoredFocus = vi.spyOn(document.querySelector("input"), "focus");
focusStateManager.restorePendingActiveElement(document.querySelector("input"));
+ expect(restoredFocus).toHaveBeenCalledWith({preventScroll: true});
expect(document.activeElement).toBe(document.querySelector("input"));
expect(matched.getAttribute("value")).toBe("Two");
});
@@ -421,8 +423,10 @@ describe("FocusStateManager", () => {
`, "text/html");
document.body.replaceWith(newDocument.body);
+ let restoredFocus = vi.spyOn(document.querySelector("input"), "focus");
focusStateManager.restoreElementState(elementState);
+ expect(restoredFocus).toHaveBeenCalledWith({preventScroll: true});
let restoredInput = document.querySelector("input");
expect(restoredInput.value).toBe("One updated");
expect(document.activeElement).toBe(restoredInput);
@@ -540,6 +544,41 @@ describe("NavigationController", () => {
expect(form.classList.contains("flux-form-waiting")).toBe(false);
});
+ it.each(["form", "link"])("renders a %s response without updating history when disabled", async (kind) => {
+ document.body.innerHTML = `
+ `;
+ let history = {pushState: vi.fn(), replaceState: vi.fn()};
+ let fetcher = vi.fn().mockResolvedValue({
+ ok: true,
+ url: "http://localhost:3000/city",
+ text: async () => "London ",
+ });
+ let controller = new NavigationController(new DOMParser(), fetcher, history);
+ let callback = vi.fn();
+ let element = document.querySelector(kind === "form" ? "form" : "a");
+ let request = () => kind === "form"
+ ? controller.submitForm(element, new FormData(element), callback)
+ : controller.clickLink(element, callback);
+
+ await request();
+
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(callback.mock.calls[0][0].querySelector("dialog").textContent).toBe("London");
+ expect(history.pushState).not.toHaveBeenCalled();
+ expect(history.replaceState).not.toHaveBeenCalled();
+ if(kind === "link") {
+ expect(callback.mock.calls[0][1]).toMatchObject({action: "clickLink", fluxScrollPreserve: true});
+ }
+
+ element.dataset.fluxHistory = "true";
+ await request();
+ expect(history.pushState).toHaveBeenCalledTimes(1);
+ expect(history.replaceState).toHaveBeenCalledTimes(1);
+ });
+
it("dispatches before-request and uses mutated request details", async () => {
document.body.innerHTML = `Next `;
@@ -1619,7 +1658,7 @@ describe("DocumentUpdater", () => {
describe("DirectiveRegistry", () => {
it("defines every supported data-flux value in one place", () => {
- expect(DirectiveRegistry.DEFINITIONS).toEqual({
+ expect(DirectiveRegistry.DEFINITIONS).toMatchObject({
"": expect.objectContaining({handler: "autoContainer"}),
"autosave": expect.objectContaining({handler: "autoSave"}),
"update": expect.objectContaining({handler: "updateOuter"}),
@@ -1965,6 +2004,42 @@ describe("FormHandler", () => {
});
describe("AutocompleteHandler", () => {
+ it("reuses server-rendered results for keyboard navigation and subsequent queries", async () => {
+ document.body.innerHTML = `
+ Search
+
+ `;
+ let navigationController = {
+ fetchForm: vi.fn((form, formData, onDocument) => {
+ onDocument(new DOMParser().parseFromString(`
+
+ `, "text/html"));
+ return Promise.resolve();
+ }),
+ };
+ let handler = new AutocompleteHandler(navigationController);
+ let form = document.querySelector("form");
+ let input = form.querySelector("input");
+ let initialResults = document.querySelector("#results");
+ handler.initAutocomplete(form);
+
+ input.focus();
+ input.dispatchEvent(new KeyboardEvent("keydown", {key: "ArrowDown", bubbles: true}));
+ expect(document.activeElement).toBe(initialResults.querySelector("a"));
+ document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key: "ArrowUp", bubbles: true}));
+ expect(document.activeElement).toBe(input);
+
+ input.value = "Tokyo";
+ await handler.updateResults(form);
+ expect(initialResults.isConnected).toBe(false);
+ expect(document.querySelectorAll("#results")).toHaveLength(1);
+ expect(form.nextElementSibling.textContent).toBe("Tokyo");
+
+ let submit = new Event("submit", {bubbles: true, cancelable: true});
+ form.dispatchEvent(submit);
+ expect(submit.defaultPrevented).toBe(false);
+ });
+
it("fetches and mounts marked results after input changes", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
@@ -2914,6 +2989,60 @@ describe("DragOrderHandler", () => {
expect(document.querySelector("[data-id='1']").classList.contains("flux-drag-order-dragging")).toBe(false);
});
+ it("constrains vertical dragging to the original column and submits the new order", () => {
+ document.body.innerHTML = `
+
+ `;
+ let submitForm = vi.fn();
+ let handler = new DragOrderHandler({submitForm}, document);
+ let [container, otherContainer] = document.querySelectorAll("ul");
+ let [item, secondItem] = container.children;
+ let form = item.querySelector("form");
+ item.getBoundingClientRect = () => ({left: 20, top: 40, width: 120, height: 50});
+ secondItem.getBoundingClientRect = () => ({left: 20, top: 90, width: 120, height: 50});
+ handler.initDragOrder(item);
+ expect(item.querySelector(".drag-handle").style.cursor).toBe("ns-resize");
+ handler.startPointerDrag({button: 0, clientX: 30, clientY: 50, pointerId: 1, preventDefault: vi.fn()}, form, item);
+ handler.moveItem(150, otherContainer, 300);
+ expect(item.parentElement).toBe(container);
+ expect(container.lastElementChild).toBe(item);
+ expect(handler.dragState.floatingItem.style.transform).toBe("translate(20px, 140px)");
+ handler.pointerUp({pointerId: 1});
+ expect(form.elements.order.value).toBe("1");
+ expect(submitForm).toHaveBeenCalledWith(form, form.querySelector("button"));
+ expect(document.querySelector(".flux-drag-order-floating")).toBe(null);
+ });
+
+ it("constrains horizontal dragging to the original column and submits the new order", () => {
+ document.body.innerHTML = `
+
+ `;
+ let submitForm = vi.fn();
+ let handler = new DragOrderHandler({submitForm}, document);
+ let [container, otherContainer] = document.querySelectorAll("ul");
+ let [item, secondItem] = container.children;
+ let form = item.querySelector("form");
+ item.getBoundingClientRect = () => ({left: 20, top: 40, width: 120, height: 50});
+ secondItem.getBoundingClientRect = () => ({left: 140, top: 40, width: 120, height: 50});
+ handler.initDragOrder(item);
+ expect(item.querySelector(".drag-handle").style.cursor).toBe("ew-resize");
+ handler.startPointerDrag({button: 0, clientX: 30, clientY: 50, pointerId: 1, preventDefault: vi.fn()}, form, item);
+ handler.moveItem(150, otherContainer, 300);
+ expect(item.parentElement).toBe(container);
+ expect(container.lastElementChild).toBe(item);
+ expect(handler.dragState.floatingItem.style.transform).toBe("translate(290px, 40px)");
+ handler.pointerUp({pointerId: 1});
+ expect(form.elements.order.value).toBe("1");
+ expect(submitForm).toHaveBeenCalledWith(form, form.querySelector("button"));
+ expect(document.querySelector(".flux-drag-order-floating")).toBe(null);
+ });
+
it("uses a floating clone while the real item reserves its place", () => {
document.body.innerHTML = `