stimeo-ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +163 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +115 -0
- package/dist/controllers/carousel_controller.js +313 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +87 -0
- package/dist/controllers/color_picker_controller.js +252 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +194 -17
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +89 -0
- package/dist/controllers/data_grid_controller.js +246 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +88 -0
- package/dist/controllers/date_range_picker_controller.js +441 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +169 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +67 -19
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +54 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +2 -2
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +142 -0
- package/dist/controllers/menubar_controller.js +785 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +141 -0
- package/dist/controllers/multi_select_controller.js +755 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +146 -0
- package/dist/controllers/navigation_menu_controller.js +511 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
- package/dist/controllers/overflow_indicator_controller.js +246 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +381 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +97 -0
- package/dist/controllers/range_slider_controller.js +192 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +238 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +101 -14
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +40 -6
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +369 -74
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +69 -0
- package/dist/controllers/separator_controller.js +96 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.js +1 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +32 -6
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +1 -1
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +9 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +109 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -4
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +295 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +42 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +231 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +5 -14
- package/dist/controllers/toast_controller.js +40 -14
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +135 -0
- package/dist/controllers/tree_view_controller.js +691 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +4256 -915
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +228 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +419 -48
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +26 -26
- package/dist/inspector/manifest.json +1054 -127
- package/package.json +5 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state, listeners, or timers, so a Turbo Drive\n * cache restore or morph re-runs `connect()` and stays consistent. A parse or\n * `Intl` error leaves the authored absolute text in place rather than throwing.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n override connect(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","file":"local_time_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state, listeners, or timers, so a Turbo Drive\n * cache restore or morph re-runs `connect()` and stays consistent. A parse or\n * `Intl` error leaves the authored absolute text in place rather than throwing.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n override connect(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n */\n #asUtc(value: string): string {\n const hasTime = /T\\d{2}:\\d{2}/.test(value);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(value);\n return hasTime && !hasZone ? `${value}Z` : value;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the element's `lang`, then the document's. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.lang || document.documentElement.lang || undefined;\n }\n}\n"]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless **Masonry** layout helper: assigns each item to the shortest column so
|
|
5
|
+
* variable-height cards pack without vertical gaps. There is no APG widget — this
|
|
6
|
+
* is a layout-only utility that emits state hooks, never visual structure.
|
|
7
|
+
*
|
|
8
|
+
* Markup contract (identifier: `stimeo--masonry`):
|
|
9
|
+
* <div data-controller="stimeo--masonry"
|
|
10
|
+
* data-stimeo--masonry-min-column-width-value="240"
|
|
11
|
+
* data-stimeo--masonry-gap-value="16">
|
|
12
|
+
* <div data-stimeo--masonry-target="item">…</div>
|
|
13
|
+
* <div data-stimeo--masonry-target="item">…</div>
|
|
14
|
+
* </div>
|
|
15
|
+
*
|
|
16
|
+
* The column count is derived responsively from the container width and
|
|
17
|
+
* `minColumnWidth`; each item is then placed into whichever column is currently
|
|
18
|
+
* shortest (measured from item heights). The count is published on the controller
|
|
19
|
+
* element as the `--stimeo-masonry-columns` custom property and each item gets a
|
|
20
|
+
* `data-column` index, so the consumer's CSS owns the actual placement.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* Behavior only. **DOM order is never changed** — reading order and focus order
|
|
24
|
+
* stay the source markup order (WCAG 1.3.2). The visual packing is purely the
|
|
25
|
+
* column assignment a consumer reads from `data-column`; this controller writes no
|
|
26
|
+
* positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),
|
|
27
|
+
* and on item add/remove ({@link MutationObserver}); both observers are released on
|
|
28
|
+
* `disconnect()` (Turbo navigation included). Use only for independent cards whose
|
|
29
|
+
* visual order carries no meaning.
|
|
30
|
+
*/
|
|
31
|
+
declare class MasonryController extends Controller<HTMLElement> {
|
|
32
|
+
#private;
|
|
33
|
+
static targets: string[];
|
|
34
|
+
static values: {
|
|
35
|
+
minColumnWidth: {
|
|
36
|
+
type: NumberConstructor;
|
|
37
|
+
default: number;
|
|
38
|
+
};
|
|
39
|
+
gap: {
|
|
40
|
+
type: NumberConstructor;
|
|
41
|
+
default: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
static events: readonly ["layout"];
|
|
45
|
+
readonly itemTargets: HTMLElement[];
|
|
46
|
+
minColumnWidthValue: number;
|
|
47
|
+
gapValue: number;
|
|
48
|
+
/** Observes size/content changes and performs the first layout pass. */
|
|
49
|
+
connect(): void;
|
|
50
|
+
/** Releases both observers and the load listener so nothing fires after detach. */
|
|
51
|
+
disconnect(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { MasonryController };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/masonry_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/layout_observer.ts
|
|
6
|
+
var LayoutObserver = class {
|
|
7
|
+
#callback;
|
|
8
|
+
#resizeObserverFactory;
|
|
9
|
+
#resizeObserver = null;
|
|
10
|
+
#observingViewport = false;
|
|
11
|
+
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
|
+
#handleViewportResize = () => {
|
|
13
|
+
this.#callback();
|
|
14
|
+
};
|
|
15
|
+
constructor(callback, options = {}) {
|
|
16
|
+
this.#callback = callback;
|
|
17
|
+
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Starts observing an element's size. Repeated calls observe additional
|
|
21
|
+
* elements through the same shared observer. No-ops when no
|
|
22
|
+
* `ResizeObserver` implementation is available.
|
|
23
|
+
*/
|
|
24
|
+
observe(element) {
|
|
25
|
+
if (!this.#resizeObserverFactory) return;
|
|
26
|
+
if (!this.#resizeObserver) {
|
|
27
|
+
this.#resizeObserver = this.#resizeObserverFactory(() => {
|
|
28
|
+
this.#callback();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
this.#resizeObserver.observe(element);
|
|
32
|
+
}
|
|
33
|
+
/** Stops observing a single element while leaving any others in place. */
|
|
34
|
+
unobserve(element) {
|
|
35
|
+
this.#resizeObserver?.unobserve(element);
|
|
36
|
+
}
|
|
37
|
+
/** Starts observing viewport resizes. Idempotent: the listener is added once. */
|
|
38
|
+
observeViewport() {
|
|
39
|
+
if (this.#observingViewport) return;
|
|
40
|
+
this.#observingViewport = true;
|
|
41
|
+
window.addEventListener("resize", this.#handleViewportResize);
|
|
42
|
+
}
|
|
43
|
+
/** Stops observing viewport resizes without affecting element observation. */
|
|
44
|
+
unobserveViewport() {
|
|
45
|
+
if (!this.#observingViewport) return;
|
|
46
|
+
this.#observingViewport = false;
|
|
47
|
+
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and
|
|
51
|
+
* removes the viewport listener. Safe to call multiple times. Call this from a
|
|
52
|
+
* controller's `disconnect()`.
|
|
53
|
+
*/
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#resizeObserver?.disconnect();
|
|
56
|
+
this.#resizeObserver = null;
|
|
57
|
+
this.unobserveViewport();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/controllers/masonry_controller.ts
|
|
62
|
+
var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
|
|
63
|
+
var MasonryController = class extends Controller {
|
|
64
|
+
static targets = ["item"];
|
|
65
|
+
static values = {
|
|
66
|
+
minColumnWidth: { type: Number, default: 240 },
|
|
67
|
+
gap: { type: Number, default: 16 }
|
|
68
|
+
};
|
|
69
|
+
static events = ["layout"];
|
|
70
|
+
#layout = new LayoutObserver(() => this.#relayout());
|
|
71
|
+
#mutationObserver = null;
|
|
72
|
+
/** Last published column count, so `layout` fires only on real changes. */
|
|
73
|
+
#lastColumns = 0;
|
|
74
|
+
/**
|
|
75
|
+
* Re-pack when a descendant resource finishes loading. Images/iframes report a
|
|
76
|
+
* height of 0 until loaded, which would skew the shortest-column packing if the
|
|
77
|
+
* first pass ran before they settled; `load` does not bubble, so this is bound in
|
|
78
|
+
* the capture phase to catch every descendant.
|
|
79
|
+
*/
|
|
80
|
+
#onLoad = () => this.#relayout();
|
|
81
|
+
/** Observes size/content changes and performs the first layout pass. */
|
|
82
|
+
connect() {
|
|
83
|
+
this.#layout.observe(this.element);
|
|
84
|
+
this.#layout.observeViewport();
|
|
85
|
+
if (typeof MutationObserver !== "undefined") {
|
|
86
|
+
this.#mutationObserver = new MutationObserver(() => this.#relayout());
|
|
87
|
+
this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
|
|
88
|
+
}
|
|
89
|
+
this.element.addEventListener("load", this.#onLoad, true);
|
|
90
|
+
this.#relayout();
|
|
91
|
+
}
|
|
92
|
+
/** Releases both observers and the load listener so nothing fires after detach. */
|
|
93
|
+
disconnect() {
|
|
94
|
+
this.#layout.disconnect();
|
|
95
|
+
this.#mutationObserver?.disconnect();
|
|
96
|
+
this.#mutationObserver = null;
|
|
97
|
+
this.element.removeEventListener("load", this.#onLoad, true);
|
|
98
|
+
this.#lastColumns = 0;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Recomputes the column count and assigns every item to the shortest column.
|
|
102
|
+
* Runs automatically on connect, on resize, on item add/remove, and when a
|
|
103
|
+
* descendant resource loads (private — there is no public action; the observers
|
|
104
|
+
* and the capture-phase `load` listener drive it). Items are walked in DOM
|
|
105
|
+
* order; each lands in the column with the least accumulated height, which
|
|
106
|
+
* keeps the packing balanced without reordering the DOM.
|
|
107
|
+
*/
|
|
108
|
+
#relayout() {
|
|
109
|
+
const items = this.itemTargets;
|
|
110
|
+
const columns = this.#columnCount();
|
|
111
|
+
const heights = new Array(columns).fill(0);
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
let shortest = 0;
|
|
114
|
+
for (let col = 1; col < columns; col++) {
|
|
115
|
+
if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
|
|
116
|
+
}
|
|
117
|
+
item.setAttribute("data-column", String(shortest));
|
|
118
|
+
heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
|
|
119
|
+
}
|
|
120
|
+
this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
|
|
121
|
+
if (columns !== this.#lastColumns) {
|
|
122
|
+
this.#lastColumns = columns;
|
|
123
|
+
this.dispatch("layout", { detail: { columns } });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,
|
|
128
|
+
* never fewer than one. When the width is unmeasurable (detached, or a layout
|
|
129
|
+
* engine that reports `0`), it falls back to a single column so every item still
|
|
130
|
+
* gets a valid `data-column`.
|
|
131
|
+
*/
|
|
132
|
+
#columnCount() {
|
|
133
|
+
const width = this.element.getBoundingClientRect().width;
|
|
134
|
+
const denominator = this.minColumnWidthValue + this.gapValue;
|
|
135
|
+
if (width <= 0 || denominator <= 0) return 1;
|
|
136
|
+
return Math.max(1, Math.floor((width + this.gapValue) / denominator));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export { MasonryController };
|
|
141
|
+
//# sourceMappingURL=masonry_controller.js.map
|
|
142
|
+
//# sourceMappingURL=masonry_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":";;;;;AAgDO,IAAM,iBAAN,MAAqB;AAAA,EACjB,SAAA;AAAA,EACA,sBAAA;AAAA,EACT,eAAA,GAAyC,IAAA;AAAA,EACzC,kBAAA,GAAqB,KAAA;AAAA;AAAA,EAGZ,wBAAwB,MAAY;AAC3C,IAAA,IAAA,CAAK,SAAA,EAAU;AAAA,EACjB,CAAA;AAAA,EAEA,WAAA,CAAY,QAAA,EAA0B,OAAA,GAAiC,EAAC,EAAG;AACzE,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AACjB,IAAA,IAAA,CAAK,sBAAA,GACH,OAAA,CAAQ,qBAAA,KACP,OAAO,cAAA,KAAmB,WAAA,GAAc,IAAA,GAAO,CAAC,EAAA,KAAO,IAAI,cAAA,CAAe,EAAE,CAAA,CAAA;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,QAAQ,OAAA,EAAwB;AAC9B,IAAA,IAAI,CAAC,KAAK,sBAAA,EAAwB;AAClC,IAAA,IAAI,CAAC,KAAK,eAAA,EAAiB;AACzB,MAAA,IAAA,CAAK,eAAA,GAAkB,IAAA,CAAK,sBAAA,CAAuB,MAAM;AACvD,QAAA,IAAA,CAAK,SAAA,EAAU;AAAA,MACjB,CAAC,CAAA;AAAA,IACH;AACA,IAAA,IAAA,CAAK,eAAA,CAAgB,QAAQ,OAAO,CAAA;AAAA,EACtC;AAAA;AAAA,EAGA,UAAU,OAAA,EAAwB;AAChC,IAAA,IAAA,CAAK,eAAA,EAAiB,UAAU,OAAO,CAAA;AAAA,EACzC;AAAA;AAAA,EAGA,eAAA,GAAwB;AACtB,IAAA,IAAI,KAAK,kBAAA,EAAoB;AAC7B,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAC1B,IAAA,MAAA,CAAO,gBAAA,CAAiB,QAAA,EAAU,IAAA,CAAK,qBAAqB,CAAA;AAAA,EAC9D;AAAA;AAAA,EAGA,iBAAA,GAA0B;AACxB,IAAA,IAAI,CAAC,KAAK,kBAAA,EAAoB;AAC9B,IAAA,IAAA,CAAK,kBAAA,GAAqB,KAAA;AAC1B,IAAA,MAAA,CAAO,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,qBAAqB,CAAA;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,UAAA,GAAmB;AACjB,IAAA,IAAA,CAAK,iBAAiB,UAAA,EAAW;AACjC,IAAA,IAAA,CAAK,eAAA,GAAkB,IAAA;AACvB,IAAA,IAAA,CAAK,iBAAA,EAAkB;AAAA,EACzB;AACF,CAAA;;;AC1GA,IAAM,gBAAA,GAAmB,0BAAA;AA8BlB,IAAM,iBAAA,GAAN,cAAgC,UAAA,CAAwB;AAAA,EAC7D,OAAgB,OAAA,GAAU,CAAC,MAAM,CAAA;AAAA,EACjC,OAAgB,MAAA,GAAS;AAAA,IACvB,cAAA,EAAgB,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,GAAA,EAAI;AAAA,IAC7C,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACnC;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAMhB,UAAU,IAAI,cAAA,CAAe,MAAM,IAAA,CAAK,WAAW,CAAA;AAAA,EAC5D,iBAAA,GAA6C,IAAA;AAAA;AAAA,EAE7C,YAAA,GAAe,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQN,OAAA,GAAU,MAAY,IAAA,CAAK,SAAA,EAAU;AAAA;AAAA,EAGrC,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,OAAA,CAAQ,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA;AACjC,IAAA,IAAA,CAAK,QAAQ,eAAA,EAAgB;AAE7B,IAAA,IAAI,OAAO,qBAAqB,WAAA,EAAa;AAC3C,MAAA,IAAA,CAAK,oBAAoB,IAAI,gBAAA,CAAiB,MAAM,IAAA,CAAK,WAAW,CAAA;AACpE,MAAA,IAAA,CAAK,iBAAA,CAAkB,QAAQ,IAAA,CAAK,OAAA,EAAS,EAAE,SAAA,EAAW,IAAA,EAAM,OAAA,EAAS,IAAA,EAAM,CAAA;AAAA,IACjF;AACA,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,MAAA,EAAQ,IAAA,CAAK,SAAS,IAAI,CAAA;AACxD,IAAA,IAAA,CAAK,SAAA,EAAU;AAAA,EACjB;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,QAAQ,UAAA,EAAW;AACxB,IAAA,IAAA,CAAK,mBAAmB,UAAA,EAAW;AACnC,IAAA,IAAA,CAAK,iBAAA,GAAoB,IAAA;AACzB,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,MAAA,EAAQ,IAAA,CAAK,SAAS,IAAI,CAAA;AAC3D,IAAA,IAAA,CAAK,YAAA,GAAe,CAAA;AAAA,EACtB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,SAAA,GAAkB;AAChB,IAAA,MAAM,QAAQ,IAAA,CAAK,WAAA;AACnB,IAAA,MAAM,OAAA,GAAU,KAAK,YAAA,EAAa;AAElC,IAAA,MAAM,UAAU,IAAI,KAAA,CAAc,OAAO,CAAA,CAAE,KAAK,CAAC,CAAA;AACjD,IAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,MAAA,IAAI,QAAA,GAAW,CAAA;AACf,MAAA,KAAA,IAAS,GAAA,GAAM,CAAA,EAAG,GAAA,GAAM,OAAA,EAAS,GAAA,EAAA,EAAO;AACtC,QAAA,IAAA,CAAK,OAAA,CAAQ,GAAG,CAAA,IAAK,CAAA,KAAM,QAAQ,QAAQ,CAAA,IAAK,IAAI,QAAA,GAAW,GAAA;AAAA,MACjE;AACA,MAAA,IAAA,CAAK,YAAA,CAAa,aAAA,EAAe,MAAA,CAAO,QAAQ,CAAC,CAAA;AACjD,MAAA,OAAA,CAAQ,QAAQ,CAAA,GAAA,CACb,OAAA,CAAQ,QAAQ,CAAA,IAAK,KAAK,IAAA,CAAK,qBAAA,EAAsB,CAAE,MAAA,GAAS,IAAA,CAAK,QAAA;AAAA,IAC1E;AAEA,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,gBAAA,EAAkB,MAAA,CAAO,OAAO,CAAC,CAAA;AAEhE,IAAA,IAAI,OAAA,KAAY,KAAK,YAAA,EAAc;AACjC,MAAA,IAAA,CAAK,YAAA,GAAe,OAAA;AACpB,MAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,OAAA,IAAW,CAAA;AAAA,IACjD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,YAAA,GAAuB;AACrB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,OAAA,CAAQ,qBAAA,EAAsB,CAAE,KAAA;AACnD,IAAA,MAAM,WAAA,GAAc,IAAA,CAAK,mBAAA,GAAsB,IAAA,CAAK,QAAA;AACpD,IAAA,IAAI,KAAA,IAAS,CAAA,IAAK,WAAA,IAAe,CAAA,EAAG,OAAO,CAAA;AAC3C,IAAA,OAAO,IAAA,CAAK,IAAI,CAAA,EAAG,IAAA,CAAK,OAAO,KAAA,GAAQ,IAAA,CAAK,QAAA,IAAY,WAAW,CAAC,CAAA;AAAA,EACtE;AACF","file":"masonry_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo-masonry-columns\";\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo-masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),\n * and on item add/remove ({@link MutationObserver}); both observers are released on\n * `disconnect()` (Turbo navigation included). Use only for independent cards whose\n * visual order carries no meaning.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: 240 },\n gap: { type: Number, default: 16 },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n readonly #layout = new LayoutObserver(() => this.#relayout());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#relayout();\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#relayout());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, and when a\n * descendant resource loads (private — there is no public action; the observers\n * and the capture-phase `load` listener drive it). Items are walked in DOM\n * order; each lands in the column with the least accumulated height, which\n * keeps the packing balanced without reordering the DOM.\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n\n const heights = new Array<number>(columns).fill(0);\n for (const item of items) {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n item.setAttribute(\"data-column\", String(shortest));\n heights[shortest] =\n (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;\n }\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.minColumnWidthValue + this.gapValue;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.gapValue) / denominator));\n }\n}\n"]}
|
|
@@ -15,13 +15,20 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
15
15
|
* data-stimeo--menu-target="menu" hidden>
|
|
16
16
|
* <li role="none">
|
|
17
17
|
* <button role="menuitem" tabindex="-1"
|
|
18
|
-
* data-stimeo--menu-target="item"
|
|
19
|
-
* data-action="click->stimeo--menu#activate
|
|
20
|
-
* keydown->stimeo--menu#onItemKeydown">…</button>
|
|
18
|
+
* data-stimeo--menu-target="item">…</button>
|
|
21
19
|
* </li>
|
|
22
20
|
* </ul>
|
|
23
21
|
* </div>
|
|
24
22
|
*
|
|
23
|
+
* The trigger's two actions are required. Items need none: their click and key
|
|
24
|
+
* handling is delegated from the controller element, so an item added, moved, or
|
|
25
|
+
* server-rendered after connect works without the consumer wiring anything onto
|
|
26
|
+
* it. The per-element form (`click->stimeo--menu#activate` /
|
|
27
|
+
* `keydown->stimeo--menu#onItemKeydown`) is supported alongside it. A
|
|
28
|
+
* focus-moving key runs once (the action claims it and the delegate stands
|
|
29
|
+
* down), and `activate`, reachable on both paths, claims the click so one
|
|
30
|
+
* gesture activates once. See {@link #onDelegatedItemClick}.
|
|
31
|
+
*
|
|
25
32
|
* Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu
|
|
26
33
|
* of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),
|
|
27
34
|
* this is a true `role="menu"` widget with roving focus across `role="menuitem"`
|
|
@@ -42,14 +49,14 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
42
49
|
* - `Escape` closes and returns focus to the trigger. While open the menu is a
|
|
43
50
|
* layer on the shared {@link EscapeLayer} stack; it claims a press only while
|
|
44
51
|
* focus is inside the controller or fell to the body, so one keypress closes
|
|
45
|
-
* exactly one layer
|
|
46
|
-
*
|
|
52
|
+
* exactly one layer and a newer layer (e.g. a tooltip shown over an item) is
|
|
53
|
+
* dismissed first.
|
|
47
54
|
* - A click outside the controller closes the menu without moving focus away
|
|
48
55
|
* from the clicked element.
|
|
49
56
|
*
|
|
50
|
-
* Roving focus skips
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
57
|
+
* Roving focus skips `hidden` and natively `disabled` items. An
|
|
58
|
+
* `aria-disabled="true"` item remains discoverable by arrow-key focus, while its
|
|
59
|
+
* activation is suppressed in the capture phase.
|
|
53
60
|
*/
|
|
54
61
|
declare class MenuController extends Controller<HTMLElement> {
|
|
55
62
|
#private;
|
|
@@ -60,7 +67,7 @@ declare class MenuController extends Controller<HTMLElement> {
|
|
|
60
67
|
readonly itemTargets: HTMLButtonElement[];
|
|
61
68
|
readonly hasTriggerTarget: boolean;
|
|
62
69
|
readonly hasMenuTarget: boolean;
|
|
63
|
-
/** Starts closed and registers
|
|
70
|
+
/** Starts closed and registers activation / outside-click listeners. */
|
|
64
71
|
connect(): void;
|
|
65
72
|
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
66
73
|
disconnect(): void;
|
|
@@ -81,8 +88,19 @@ declare class MenuController extends Controller<HTMLElement> {
|
|
|
81
88
|
onTriggerKeydown(event: KeyboardEvent): void;
|
|
82
89
|
/** Implements roving focus and closing keys inside the menu. */
|
|
83
90
|
onItemKeydown(event: KeyboardEvent): void;
|
|
84
|
-
/**
|
|
85
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Closes the menu after an item is activated. Bound via `data-action`, and
|
|
93
|
+
* also reached from the delegated listener.
|
|
94
|
+
*
|
|
95
|
+
* Markup that carries the per-element action *and* gets the delegate would run
|
|
96
|
+
* this twice for one gesture. `close()` writes the state hooks (`hidden`,
|
|
97
|
+
* `aria-expanded`), and an identical reassign still queues a MutationRecord,
|
|
98
|
+
* so a second pass is observable to anyone watching them. The event is
|
|
99
|
+
* therefore claimed: the path that gets there first does the work, the other
|
|
100
|
+
* one finds it claimed and returns. A programmatic call with no event always
|
|
101
|
+
* runs.
|
|
102
|
+
*/
|
|
103
|
+
activate(event?: Event): void;
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
export { MenuController };
|
|
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/menu_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// src/utils/escape_layer.ts
|
|
6
12
|
function claimsWhileFocusWithin(element) {
|
|
7
13
|
return () => {
|
|
@@ -159,18 +165,30 @@ var MenuController = class extends Controller {
|
|
|
159
165
|
#timers = new SafeTimeout();
|
|
160
166
|
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
161
167
|
#escapeLayer = new EscapeLayer();
|
|
162
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* The item a click started on, recorded in the capture pass. Read once by the
|
|
170
|
+
* delegated bubble listener, which must not re-derive it: consumer handlers run
|
|
171
|
+
* in between and may detach the item.
|
|
172
|
+
*/
|
|
173
|
+
#clickOwner = null;
|
|
174
|
+
/** Clicks already turned into an activation, so the two paths run it once. */
|
|
175
|
+
#activated = /* @__PURE__ */ new WeakSet();
|
|
176
|
+
/** Starts closed and registers activation / outside-click listeners. */
|
|
163
177
|
connect() {
|
|
164
178
|
this.close();
|
|
165
179
|
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
166
|
-
|
|
180
|
+
this.element.addEventListener("click", this.#onDelegatedItemClick);
|
|
181
|
+
this.element.addEventListener("keydown", this.#onDelegatedItemKeydown);
|
|
182
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
167
183
|
}
|
|
168
184
|
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
169
185
|
disconnect() {
|
|
170
186
|
this.#timers.clearAll();
|
|
171
187
|
this.#escapeLayer.deactivate();
|
|
172
188
|
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
173
|
-
|
|
189
|
+
this.element.removeEventListener("click", this.#onDelegatedItemClick);
|
|
190
|
+
this.element.removeEventListener("keydown", this.#onDelegatedItemKeydown);
|
|
191
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
174
192
|
}
|
|
175
193
|
/** Toggles the menu open/closed. Bound via `data-action` (click). */
|
|
176
194
|
toggle() {
|
|
@@ -209,6 +227,8 @@ var MenuController = class extends Controller {
|
|
|
209
227
|
* open and then immediately re-toggle the menu.
|
|
210
228
|
*/
|
|
211
229
|
onTriggerKeydown(event) {
|
|
230
|
+
if (event.defaultPrevented) return;
|
|
231
|
+
if (isReservedArrowChord(event)) return;
|
|
212
232
|
if (event.key === "ArrowDown") {
|
|
213
233
|
event.preventDefault();
|
|
214
234
|
this.open();
|
|
@@ -221,8 +241,18 @@ var MenuController = class extends Controller {
|
|
|
221
241
|
}
|
|
222
242
|
/** Implements roving focus and closing keys inside the menu. */
|
|
223
243
|
onItemKeydown(event) {
|
|
244
|
+
this.#handleItemKeydown(event, event.currentTarget);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The roving-focus body, shared by the per-element action and its delegated
|
|
248
|
+
* twin. `from` is the item the key belongs to: the bound element for the
|
|
249
|
+
* action, the resolved owner of the event target for the delegate.
|
|
250
|
+
*/
|
|
251
|
+
#handleItemKeydown(event, from) {
|
|
252
|
+
if (event.defaultPrevented) return;
|
|
253
|
+
if (isReservedArrowChord(event)) return;
|
|
224
254
|
const items = this.#navigableItems;
|
|
225
|
-
const currentIndex = items.indexOf(
|
|
255
|
+
const currentIndex = items.indexOf(from);
|
|
226
256
|
switch (event.key) {
|
|
227
257
|
case "ArrowDown":
|
|
228
258
|
event.preventDefault();
|
|
@@ -246,8 +276,23 @@ var MenuController = class extends Controller {
|
|
|
246
276
|
break;
|
|
247
277
|
}
|
|
248
278
|
}
|
|
249
|
-
/**
|
|
250
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Closes the menu after an item is activated. Bound via `data-action`, and
|
|
281
|
+
* also reached from the delegated listener.
|
|
282
|
+
*
|
|
283
|
+
* Markup that carries the per-element action *and* gets the delegate would run
|
|
284
|
+
* this twice for one gesture. `close()` writes the state hooks (`hidden`,
|
|
285
|
+
* `aria-expanded`), and an identical reassign still queues a MutationRecord,
|
|
286
|
+
* so a second pass is observable to anyone watching them. The event is
|
|
287
|
+
* therefore claimed: the path that gets there first does the work, the other
|
|
288
|
+
* one finds it claimed and returns. A programmatic call with no event always
|
|
289
|
+
* runs.
|
|
290
|
+
*/
|
|
291
|
+
activate(event) {
|
|
292
|
+
if (event !== void 0) {
|
|
293
|
+
if (this.#activated.has(event)) return;
|
|
294
|
+
this.#activated.add(event);
|
|
295
|
+
}
|
|
251
296
|
this.#closeAndRestore();
|
|
252
297
|
}
|
|
253
298
|
/** Closes and returns focus to the trigger (Escape / item-activation path). */
|
|
@@ -260,16 +305,59 @@ var MenuController = class extends Controller {
|
|
|
260
305
|
if (this.#isOpen && !this.element.contains(event.target)) this.close();
|
|
261
306
|
};
|
|
262
307
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
308
|
+
* Delegated twin of {@link onItemKeydown}, bound on the controller element.
|
|
309
|
+
*
|
|
310
|
+
* An item that arrives *after* connect — Overflow Menu moves toolbar controls
|
|
311
|
+
* into this menu at runtime — carries whatever `data-action` its author wrote,
|
|
312
|
+
* and that is exactly the binding a consumer forgets, because the markup that
|
|
313
|
+
* declares the item lives nowhere near the menu. Listening on the container
|
|
314
|
+
* makes membership in the `item` target enough to be operable. The per-element
|
|
315
|
+
* action stays supported and is not double-handled: see
|
|
316
|
+
* {@link #handleItemKeydown}.
|
|
317
|
+
*/
|
|
318
|
+
#onDelegatedItemKeydown = (event) => {
|
|
319
|
+
const item = this.#itemFrom(event.target);
|
|
320
|
+
if (item === null) return;
|
|
321
|
+
this.#handleItemKeydown(event, item);
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Delegated twin of {@link activate}, for the same reason as the keydown one.
|
|
325
|
+
*
|
|
326
|
+
* It works from the item the capture pass recorded, not from a fresh lookup:
|
|
327
|
+
* by the time a click bubbles up here, a consumer handler may already have
|
|
328
|
+
* detached the row it lives in, and `itemTargets` is a live query that would
|
|
329
|
+
* then resolve nothing — the activation would be lost and the menu left open
|
|
330
|
+
* over an item that no longer exists. `aria-disabled` items never reach this
|
|
331
|
+
* listener at all: the capture pass stops the click outright.
|
|
332
|
+
*/
|
|
333
|
+
#onDelegatedItemClick = (event) => {
|
|
334
|
+
const item = this.#clickOwner;
|
|
335
|
+
this.#clickOwner = null;
|
|
336
|
+
if (item === null) return;
|
|
337
|
+
this.activate(event);
|
|
338
|
+
};
|
|
339
|
+
/** The item target that is, or contains, `node`; `null` when it is neither. */
|
|
340
|
+
#itemFrom(node) {
|
|
341
|
+
if (!(node instanceof Node)) return null;
|
|
342
|
+
return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* First look at every click inside the controller, and the only point at which
|
|
346
|
+
* the DOM is still the one the user clicked. Two jobs: record which item owns
|
|
347
|
+
* the click for the delegate above, and stop an `aria-disabled` command before
|
|
348
|
+
* it reaches consumer handlers (native Enter/Space activation synthesizes a
|
|
349
|
+
* click and is blocked here too).
|
|
265
350
|
*/
|
|
266
351
|
#onItemClickCapture = (event) => {
|
|
267
352
|
const target = event.target;
|
|
268
|
-
if (!(target instanceof Node))
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
353
|
+
if (!(target instanceof Node)) {
|
|
354
|
+
this.#clickOwner = null;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const item = this.#itemFrom(target);
|
|
358
|
+
this.#clickOwner = item;
|
|
359
|
+
if (item === null || item.getAttribute("aria-disabled") !== "true") return;
|
|
360
|
+
this.#clickOwner = null;
|
|
273
361
|
event.preventDefault();
|
|
274
362
|
event.stopImmediatePropagation();
|
|
275
363
|
};
|
|
@@ -287,13 +375,12 @@ var MenuController = class extends Controller {
|
|
|
287
375
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
288
376
|
}
|
|
289
377
|
/**
|
|
290
|
-
* An item can take roving focus unless it is `hidden
|
|
291
|
-
*
|
|
292
|
-
*
|
|
378
|
+
* An item can take roving focus unless it is `hidden` or a natively `disabled`
|
|
379
|
+
* form control. `aria-disabled` remains focusable for discoverability; capture
|
|
380
|
+
* suppresses activation. CSS-only visibility is the consumer's responsibility.
|
|
293
381
|
*/
|
|
294
382
|
#isNavigable(item) {
|
|
295
383
|
if (item.hasAttribute("hidden")) return false;
|
|
296
|
-
if (item.getAttribute("aria-disabled") === "true") return false;
|
|
297
384
|
return !item.disabled;
|
|
298
385
|
}
|
|
299
386
|
/** Whether the menu is currently visible. */
|