Compare commits

...
Author SHA1 Message Date
ajnart 8c18dfef42 test: remove obsolete advanced interactions e2e 2026-08-14 15:15:12 +02:00
ajnart f3946388fc test: remove advanced focus support assertion 2026-08-14 14:45:20 +02:00
ajnart 08588da0d1 test: stabilize advanced view keyboard flow 2026-08-14 11:31:55 +02:00
ajnart 464f30799a fix: refine advanced widget access 2026-08-14 11:07:09 +02:00
ajnart bd4bbcaca2 chore: reconcile widget layer after stack rebase 2026-08-14 11:07:09 +02:00
Thomas Camlong 09b82ba171 fix: limit advanced widget focus 2026-08-14 11:07:09 +02:00
Thomas Camlong f38eb034da fix: address widget review findings 2026-08-14 11:07:09 +02:00
Thomas Camlong a3a89db727 test: align widget modernization fixtures 2026-08-14 11:07:09 +02:00
Thomas Camlong c099a8eef6 feat: expand advanced widget experiences 2026-08-14 11:07:09 +02:00
Thomas Camlong 9437d211c8 chore: drop manual translations 2026-08-14 11:07:09 +02:00
Thomas Camlong 096cf56ac9 fix: rework media session details 2026-08-14 11:07:08 +02:00
Thomas Camlong 851129e62e fix: refine Beszel stats period controls 2026-08-14 11:07:08 +02:00
Thomas Camlong cc1b22f052 fix: show complete Immich photos in advanced mode 2026-08-14 11:07:08 +02:00
Thomas Camlong 5df4358f0d test: preserve advanced preview hover ownership 2026-08-14 11:07:08 +02:00
Thomas Camlong 821b5a555d feat: improve advanced preview activation 2026-08-14 11:07:08 +02:00
Thomas Camlong a0f4a58386 fix: smooth advanced focus entrance 2026-08-14 11:07:08 +02:00
Thomas Camlong 7c840bcfa0 Remove Docker advanced focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 38d30acb87 fix: require advanced widget focus opt-in 2026-08-14 11:07:08 +02:00
Thomas Camlong ae515584cc test(widgets): stabilize advanced focus hover 2026-08-14 11:07:08 +02:00
Thomas Camlong 9338d6d4ec test(mcp): review smart home entity details tool 2026-08-14 11:07:08 +02:00
Thomas Camlong d98b7797c1 style(widgets): split query matcher type imports 2026-08-14 11:07:08 +02:00
Thomas Camlong 3c739dc636 fix(widgets): keep board graph imports scoped 2026-08-14 11:07:08 +02:00
Thomas Camlong 639cb8d35f fix(ci): make modernization independently installable 2026-08-14 11:07:08 +02:00
Thomas Camlong f1ac482684 style: format modernization notes 2026-08-14 11:07:08 +02:00
Thomas Camlong 9cc7fd2051 fix: preserve app links and hover previews 2026-08-14 11:07:08 +02:00
Thomas Camlong d4d951b99c fix: stabilize advanced widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong a4f115064e fix: finish widget modernization audit 2026-08-14 11:07:08 +02:00
Thomas Camlong 07e0642192 fix: address widget review feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 965ccb32d6 fix: scope advanced preview controls 2026-08-14 11:07:08 +02:00
Thomas Camlong be08f70c8d fix: restore notifications query scope 2026-08-14 11:07:08 +02:00
Thomas Camlong 4892c399ba fix: remove stale notifications import 2026-08-14 11:07:08 +02:00
ajnart 687fb323f7 style: auto-fix lint and format 2026-08-14 11:07:08 +02:00
Thomas Camlong 56cc021f98 fix: refine widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong 836a4cd46b feat: modernize widget experience 2026-08-14 11:07:08 +02:00
ajnart a607dff50a docs: add widget modernization audit 2026-08-14 11:07:08 +02:00
Thomas Camlong 68751d85dc restore context menu focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 869dd45de5 fix context menu keyboard focus 2026-08-14 11:07:08 +02:00
Thomas Camlong 352aa7dd64 fix board E2E login race 2026-08-14 11:07:08 +02:00
Thomas Camlong 801221d2da fix SVG focus scrolling 2026-08-14 11:07:08 +02:00
Thomas Camlong 861538e9a0 fix focused pane scrolling 2026-08-14 11:07:08 +02:00
Thomas Camlong cb7b49b3e7 fix focus scroll layering 2026-08-14 11:07:08 +02:00
Thomas Camlong f5cced4080 fix widget focus interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong e429d7540a fix: align responsive widget actions 2026-08-14 11:07:08 +02:00
Thomas Camlong 3bd80dcc58 fix: simplify widget feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 0e01ac67df fix: harden widget error feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 8259df58f6 fix: address final widget review 2026-08-14 11:07:08 +02:00
Thomas Camlong 3485ef0877 fix: localize widget chart formatting 2026-08-14 11:07:08 +02:00
Thomas Camlong 2214acd17e fix: align widget accessibility and locales 2026-08-14 11:07:08 +02:00
Thomas Camlong 25b835dca9 fix: canonicalize widget date locales 2026-08-14 11:07:08 +02:00
Thomas Camlong 2efa6bcd4c fix: close final widget review gaps 2026-08-14 11:07:08 +02:00
Thomas Camlong c67c893f8f fix: harden widget review follow-ups 2026-08-14 11:07:08 +02:00
Thomas Camlong e7e44375ef fix: address widget review findings 2026-08-14 11:07:08 +02:00
Thomas Camlong 842e86012d feat: refine widget interactions 2026-08-14 11:07:08 +02:00
Thomas Camlong a8a7545f40 fix: secure widget navigation links 2026-08-14 11:07:08 +02:00
Thomas Camlong ed7b30dc5f fix: polish widget failure feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong c09ed96978 fix: address final widget review findings 2026-08-14 11:07:08 +02:00
Thomas Camlong 44b55a401c fix: complete widget review cleanup 2026-08-14 11:07:08 +02:00
Thomas Camlong f00f5b83b5 fix: address remaining widget review feedback 2026-08-14 11:07:08 +02:00
Thomas Camlong 5553c4e0c5 fix: allow dynamic database schema access 2026-08-14 11:07:08 +02:00
Thomas Camlong 6cdda54e10 fix: make integration warnings keyboard accessible 2026-08-14 11:07:08 +02:00
Thomas Camlong 0f9d038aaf fix: complete widget modernization review 2026-08-14 11:07:08 +02:00
Thomas Camlong a38e731209 fix: address widget review feedback 2026-08-14 11:07:08 +02:00
ajnart b1b17ad69f style: auto-fix lint and format 2026-08-14 11:07:08 +02:00
Thomas Camlong add40370aa fix: refine widget modernization 2026-08-14 11:07:07 +02:00
Thomas Camlong a2763d1f6f feat: modernize dashboard widgets 2026-08-14 11:07:07 +02:00
ajnart f3048f4804 docs: correct board interaction guidance 2026-08-14 11:07:07 +02:00
ajnart 6bfa9c33d0 chore: reconcile grid layer after stack rebase 2026-08-14 11:07:07 +02:00
Thomas Camlong 16982aa178 fix: simplify grid interaction previews 2026-08-14 11:07:07 +02:00
Thomas Camlong a2400f8412 perf: isolate board grid previews 2026-08-14 11:07:07 +02:00
Thomas Camlong 6e455760a3 fix: disable app context menu 2026-08-14 11:07:07 +02:00
Thomas Camlong 0b9b77c632 perf: isolate grid interaction renders 2026-08-14 11:07:07 +02:00
Thomas Camlong 346bff540f fix: stabilize rail and resize interactions 2026-08-14 11:07:07 +02:00
Thomas Camlong f8771c371f chore: leave translations to Crowdin 2026-08-14 11:07:07 +02:00
Thomas Camlong 71cc3abbe9 fix: prevent horizontal rail auto-scroll 2026-08-14 11:07:07 +02:00
Thomas Camlong a098c42013 Revert "ci: use self-hosted runners for stack"
This reverts commit a1d80515b0e00240bb662180626a44bfb205656e.
2026-08-14 11:07:07 +02:00
Thomas Camlong 4cf83443fd ci: use self-hosted runners for stack 2026-08-14 11:07:07 +02:00
Thomas Camlong f5b4889663 fix: anchor dashboard rail labels 2026-08-14 11:07:07 +02:00
Thomas Camlong 06aa5caf0b fix: size dashboard rails to viewport 2026-08-14 11:07:07 +02:00
Thomas Camlong d3534dfcf8 style: format board grid test 2026-08-14 11:07:07 +02:00
Thomas Camlong f7591dd196 test: allow dnd-kit DOM modifier import 2026-08-14 11:07:07 +02:00
Thomas Camlong 0c5f7a45bb fix: preserve narrow board interactions 2026-08-14 11:07:07 +02:00
Thomas Camlong cdbba92f31 fix: keep scaled board rails sticky 2026-08-14 11:07:07 +02:00
Thomas Camlong 3b0633f6a8 test: assert clipped zoom overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong d0880aa04c test: avoid physical zoom size assumption 2026-08-14 11:07:07 +02:00
Thomas Camlong 7cf6a5d5ab test: assert zoomed canvas fit invariant 2026-08-14 11:07:07 +02:00
Thomas Camlong d5763737c3 style: format board grid coverage 2026-08-14 11:07:07 +02:00
Thomas Camlong 00b0b215b0 fix: stabilize board rails 2026-08-14 11:07:07 +02:00
Thomas Camlong 5f08327c5c Fix board grid bounds and live growth 2026-08-14 11:07:07 +02:00
Thomas Camlong 2645cb426a fix: accept legacy dynamic sections 2026-08-14 11:07:07 +02:00
Thomas Camlong 65e866064e Rename layout gutter migrations 2026-08-14 11:07:07 +02:00
Thomas Camlong 392df12d8a style: format dnd migration metadata 2026-08-14 11:07:07 +02:00
Thomas Camlong 6209980b27 test(e2e): wait for settled grid geometry 2026-08-14 11:07:07 +02:00
Thomas Camlong 3f04cf83a2 fix container header spacing 2026-08-14 11:07:07 +02:00
Thomas Camlong e5a8194c03 fix: contain board canvas overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong 90cf0e1127 fix: contain collapsed layout and widget overflow 2026-08-14 11:07:07 +02:00
Thomas Camlong 498481d539 test: wait for resized grid geometry 2026-08-14 11:07:07 +02:00
Thomas Camlong 65da74858b test: stabilize board grid authentication 2026-08-14 11:07:07 +02:00
ajnart af2b7b3157 style: auto-fix lint and format 2026-08-14 11:07:07 +02:00
Thomas Camlong db40779492 fix: smooth container collapse behavior 2026-08-14 11:07:07 +02:00
ajnart 91aeac8377 style: auto-fix lint and format 2026-08-14 11:07:06 +02:00
Thomas Camlong dfe2ddf34b fix: refine container interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 34f2a0bd24 fix: polish board grid interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 4227e392b5 fix: address board grid CI and review 2026-08-14 11:07:06 +02:00
ajnart 6893205ee0 style: auto-fix lint and format 2026-08-14 11:07:06 +02:00
Thomas Camlong 28ac23e66b fix: address board grid review 2026-08-14 11:07:06 +02:00
Thomas Camlong cddae6719e feat: rebuild board grid with dnd-kit 2026-08-14 11:07:06 +02:00
ajnart 0cb75982fc test: wait for grid reconciliation between drags 2026-08-14 11:07:06 +02:00
ajnart 36de8131c8 fix: clarify board grid editing affordances 2026-08-14 11:07:06 +02:00
ajnart 2f28f45ff1 fix: improve board grid interactions 2026-08-14 11:07:06 +02:00
Thomas Camlong 915af9ae96 test: target visible layout preview 2026-08-14 11:07:06 +02:00
Thomas Camlong db5d088330 test: tolerate sticky rail subpixel rounding 2026-08-14 11:07:06 +02:00
Thomas Camlong d1a2ed8fd7 test: avoid grid drag rounding boundary 2026-08-14 11:07:06 +02:00
ajnart d6b114e423 feat: rebuild board grid experience 2026-08-14 11:07:06 +02:00
618 changed files with 43448 additions and 7835 deletions

No files matched your search

+475
View File
@@ -0,0 +1,475 @@
# Mantine UI - LLM Documentation
This index lists Mantine documentation pages formatted for LLMs.
Each link points to a standalone Markdown file under the /llms path.
For a single consolidated file with all content, use:
- https://mantine.dev/llms-full.txt
## FAQ
- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features
- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size
- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme
- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object
- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS
- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations
- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage
- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context
- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine
- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles
- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries
- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files
- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine
- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine.
- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value
- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles
- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component
- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover
- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components
- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color
- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme
- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs
- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component
- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code
- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component
- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme
- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handle focus with use-form hook
- [How can I get current color scheme value in JavaScript?](https://mantine.dev/llms/q-how-to-get-color-scheme-value-in-js.md): How to use useMantineColorScheme and useComputedColorScheme hooks to get current color scheme value in JavaScript
- [How can I lint CSS files?](https://mantine.dev/llms/q-how-to-setup-stylelint.md): Learn how to set up Stylelint for Mantine postcss syntax
- [How can I load fonts in Next.js?](https://mantine.dev/llms/q-next-load-fonts.md): A guide to load custom fonts in Next.js with CSS and next/font package
- [How can I load fonts in React Router?](https://mantine.dev/llms/q-react-router-load-fonts.md): A guide to load custom fonts in React Router
- [How can I load fonts in Vite?](https://mantine.dev/llms/q-vite-load-fonts.md): A guide to load custom fonts in Vite
- [How can I lock scroll in my application?](https://mantine.dev/llms/q-how-to-lock-scroll.md): Use react-remove-scroll library to lock scroll in your application
- [How can I submit a template to Mantine documentation?](https://mantine.dev/llms/q-submit-template.md): Learn how to create and submit a template to Mantine documentation
- [How can I test Modal/Drawer/Popover components?](https://mantine.dev/llms/q-portals-testing.md): Learn how to use react-testing-library to test components that use portals and transitions.
- [How can I test Select/MultiSelect components?](https://mantine.dev/llms/q-combobox-testing.md): Learn how to use react-testing-library to test Select and MultiSelect components.
- [How can I upload files from Dropzone component?](https://mantine.dev/llms/q-dropzone-upload.md): Learn how to process files dropped into Dropzone component
- [How is that thing done on the mantine.dev website?](https://mantine.dev/llms/q-how-that-thing-is-done.md): Learn how various elements are implemented on the mantine.dev website
- [How to align input with a button in a flex container?](https://mantine.dev/llms/q-align-input-button.md): Learn how to align Mantine inputs with buttons using Group component
- [How to call a function when Modal/Drawer closes and animation completes?](https://mantine.dev/llms/q-how-to-call-function-when-modal-closes.md): How to use transitionProps in Modal/Drawer components
- [How to change background color for FiveM?](https://mantine.dev/llms/q-fivem-background-color.md): Unset body background color and color-scheme styles for FiveM
- [How to change input placeholder color?](https://mantine.dev/llms/q-inputs-placeholder-color.md): Learn how to change placeholder color with Styles API
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/_, @mantinex/_ and postcss-preset-mantine dependencies manually or with ncu script
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
- [Is there a comparison with other libraries?](https://mantine.dev/llms/q-other-libs.md): Comparison with other libraries is not provided, you are trusted to make your own decision based on documentation examples
- [Is there a floating action button component?](https://mantine.dev/llms/q-floating-action-button.md): No, but you can build it with Affix and ActionIcon components
- [Is there DataGrid component that I can use with Mantine?](https://mantine.dev/llms/q-data-grid-i-need.md): A list of community packages that provide DataGrid component that can be used with Mantine.
- [It is not possible to pinch to zoom when a Modal is opened. What should I do?](https://mantine.dev/llms/q-pinch-to-zoom-modal.md): Use removeScrollProps to configure react-remove-scroll options
- [MantineProvider was not found in component tree. What should I do?](https://mantine.dev/llms/q-mantine-provider-missing.md): Learn how to resolve MantineProvider issues
- [My buttons are transparent and the background is visible only on hover, what is wrong?](https://mantine.dev/llms/q-transparent-buttons.md): You have installed a third-party library that overrides Mantine styles
- [My Popover dropdown closes when I click on the dropdown of a nested Popover](https://mantine.dev/llms/q-nested-popover-closes.md): Popover dropdown is closed when it detects click-outside events
- [My styles are broken with disabled JavaScript. What should I do?](https://mantine.dev/llms/q-disabled-js.md): Learn how to use Mantine without JavaScript enabled
- [My styles are overridden by Mantine components styles, what should I do?](https://mantine.dev/llms/q-styles-order.md): Learn how to use CSS layers to control styles order and prevent Mantine components from overriding your styles
- [Native browser validation does not work in some components, what should I do?](https://mantine.dev/llms/q-native-required.md): Learn why native browser validation does not work in some components
- [The use-local-storage hook returns the real value only after mounting. Is it a bug?](https://mantine.dev/llms/q-local-storage-effect.md): Learn how Mantine retrieves local storage value
- [What is the difference between searchable Select and Autocomplete?](https://mantine.dev/llms/q-select-autocomplete-difference.md): Searchable Select and Autocomplete are similar components, but they serve different purposes.
- [Where can I find the roadmap?](https://mantine.dev/llms/q-roadmap.md): The roadmap is private and only available to the team.
- [Why are my Carousel slides in vertical orientation?](https://mantine.dev/llms/q-carousel-missing-styles.md): You forgot to import carousel styles
- [Why are my notifications displayed at the wrong position?](https://mantine.dev/llms/q-notifications-missing-styles.md): Because you did not import styles
- [Why can I not use one polymorphic component in the component prop of another polymorphic component?](https://mantine.dev/llms/q-polymorphic-in-polymorphic.md): Learn how polymorphic components types work
- [Why can I not use value/label data structure with Autocomplete/TagsInput?](https://mantine.dev/llms/q-autocomplete-value-label.md): Learn the difference between Autocomplete and Select components
- [Why can VSCode not autoimport the Text component?](https://mantine.dev/llms/q-why-vscode-cannot-autoimport-text.md): It is confused by the native Text constructor
- [Why do I see color scheme flickering on page load?](https://mantine.dev/llms/q-color-scheme-flickering.md): Color scheme flickering is caused by incorrect usage of ColorSchemeScript
- [Why do my dates components look broken?](https://mantine.dev/llms/q-dates-missing-styles.md): Because you did not import styles
- [Why does FileButton not work in Menu?](https://mantine.dev/llms/q-file-button-in-menu.md): Learn how to use FileButton in Menu component
- [Why is it required to have 10 shades per color?](https://mantine.dev/llms/q-ten-shades-per-color.md): Learn how Mantine uses theme colors
- [Why is my screen completely empty after I](https://mantine.dev/llms/q-notifications-empty-screen.md): You have used Notifications component incorrectly
## Core
- [Accordion](https://mantine.dev/llms/core-accordion.md): Divide content into collapsible sections
- [ActionIcon](https://mantine.dev/llms/core-action-icon.md): Icon button
- [Affix](https://mantine.dev/llms/core-affix.md): Renders children inside portal at fixed position
- [Alert](https://mantine.dev/llms/core-alert.md): Attract user attention with important static message
- [AlphaSlider](https://mantine.dev/llms/core-alpha-slider.md): Slider component for selecting alpha channel in color pickers (0 – 1)
- [Anchor](https://mantine.dev/llms/core-anchor.md): Display link with theme styles
- [AngleSlider](https://mantine.dev/llms/core-angle-slider.md): Pick angle value between 0 and 360
- [AppShell](https://mantine.dev/llms/core-app-shell.md): Responsive shell for your application with header, navbar, aside and footer
- [AspectRatio](https://mantine.dev/llms/core-aspect-ratio.md): Maintain responsive consistent width/height ratio
- [Autocomplete](https://mantine.dev/llms/core-autocomplete.md): Autocomplete user input with any list of options
- [Avatar](https://mantine.dev/llms/core-avatar.md): Display user profile image, initials or fallback icon
- [BackgroundImage](https://mantine.dev/llms/core-background-image.md): Displays image as background
- [Badge](https://mantine.dev/llms/core-badge.md): Display badge, pill or tag
- [Blockquote](https://mantine.dev/llms/core-blockquote.md): Blockquote with optional cite
- [Box](https://mantine.dev/llms/core-box.md): Base component for all Mantine components
- [Breadcrumbs](https://mantine.dev/llms/core-breadcrumbs.md): Separates list of react nodes with given separator
- [Burger](https://mantine.dev/llms/core-burger.md): Open/close navigation button
- [Button](https://mantine.dev/llms/core-button.md): Button component to render button or link
- [Card](https://mantine.dev/llms/core-card.md): Card with sections
- [Cascader](https://mantine.dev/llms/core-cascader.md): Select a path from hierarchical data with cascading columns
- [Center](https://mantine.dev/llms/core-center.md): Centers content vertically and horizontally
- [Checkbox](https://mantine.dev/llms/core-checkbox.md): Capture boolean input from user
- [Chip](https://mantine.dev/llms/core-chip.md): Pick one or multiple values with inline controls
- [CloseButton](https://mantine.dev/llms/core-close-button.md): Button with close icon
- [Code](https://mantine.dev/llms/core-code.md): Inline and block code
- [Collapse](https://mantine.dev/llms/core-collapse.md): Animate presence with slide down/up transition
- [ColorInput](https://mantine.dev/llms/core-color-input.md): Capture color from user
- [ColorPicker](https://mantine.dev/llms/core-color-picker.md): Pick colors in hex(a), rgb(a), hsl(a) and hsv(a) formats
- [ColorSwatch](https://mantine.dev/llms/core-color-swatch.md): Displays color
- [Combobox](https://mantine.dev/llms/core-combobox.md): Create custom select, autocomplete or multiselect inputs
- [ComboboxPopover](https://mantine.dev/llms/core-combobox-popover.md): Add combobox dropdown to any button element
- [Container](https://mantine.dev/llms/core-container.md): Center content with padding and max-width
- [CopyButton](https://mantine.dev/llms/core-copy-button.md): Copies given text to clipboard
- [CorePackage](https://mantine.dev/llms/core-package.md)
- [DataList](https://mantine.dev/llms/core-data-list.md): Display label-value pairs as a description list
- [Dialog](https://mantine.dev/llms/core-dialog.md): Display a fixed overlay dialog at any side of the screen
- [Divider](https://mantine.dev/llms/core-divider.md): Horizontal line with optional label or vertical divider
- [Drawer](https://mantine.dev/llms/core-drawer.md): Display overlay area at any side of the screen
- [EmptyState](https://mantine.dev/llms/core-empty-state.md): Display a placeholder for no data, empty results and first-run states
- [Fieldset](https://mantine.dev/llms/core-fieldset.md): Group related elements in a form
- [FileButton](https://mantine.dev/llms/core-file-button.md): Open file picker with a button click
- [FileInput](https://mantine.dev/llms/core-file-input.md): Capture files from user
- [Flex](https://mantine.dev/llms/core-flex.md): Compose elements in a flex container
- [FloatingIndicator](https://mantine.dev/llms/core-floating-indicator.md): Display a floating indicator over a group of elements
- [FloatingWindow](https://mantine.dev/llms/core-floating-window.md): Draggable floating area
- [FocusTrap](https://mantine.dev/llms/core-focus-trap.md): Trap focus at child node
- [Grid](https://mantine.dev/llms/core-grid.md): Responsive 12 columns grid system
- [Group](https://mantine.dev/llms/core-group.md): Compose elements and components in a horizontal flex container
- [Highlight](https://mantine.dev/llms/core-highlight.md): Highlight given part of a string with mark
- [HoverCard](https://mantine.dev/llms/core-hover-card.md): Display popover section when target element is hovered
- [HueSlider](https://mantine.dev/llms/core-hue-slider.md): Slider component for selecting hue channel in color pickers (0 – 360 degrees)
- [Image](https://mantine.dev/llms/core-image.md): Image with optional fallback
- [Indicator](https://mantine.dev/llms/core-indicator.md): Display element at the corner of another element
- [Input](https://mantine.dev/llms/core-input.md): Base component to create custom inputs
- [JsonInput](https://mantine.dev/llms/core-json-input.md): Capture json data from user
- [Kbd](https://mantine.dev/llms/core-kbd.md): Display keyboard key
- [List](https://mantine.dev/llms/core-list.md): Display ordered or unordered list
- [Loader](https://mantine.dev/llms/core-loader.md): Indicate loading state
- [LoadingOverlay](https://mantine.dev/llms/core-loading-overlay.md): An overlay with centered loader
- [Mark](https://mantine.dev/llms/core-mark.md): Highlight part of the text
- [Marquee](https://mantine.dev/llms/core-marquee.md): Create continuous scrolling animation for content
- [MaskInput](https://mantine.dev/llms/core-mask-input.md): Input with mask pattern for formatted text entry
- [Menu](https://mantine.dev/llms/core-menu.md): Combine a list of secondary actions into single interactive area
- [Menubar](https://mantine.dev/llms/core-menubar.md): Desktop application style menubar with a row of menu triggers
- [Modal](https://mantine.dev/llms/core-modal.md): An accessible overlay dialog
- [MultiSelect](https://mantine.dev/llms/core-multi-select.md): Custom searchable multi select
- [NativeSelect](https://mantine.dev/llms/core-native-select.md): Native select element based on Input
- [NavLink](https://mantine.dev/llms/core-nav-link.md): Navigation link
- [Notification](https://mantine.dev/llms/core-notification.md): Show dynamic notifications and alerts to user, part of notifications system
- [NumberFormatter](https://mantine.dev/llms/core-number-formatter.md): Format number with thousands/decimal separators and suffix/prefix
- [NumberInput](https://mantine.dev/llms/core-number-input.md): Capture number from user
- [OverflowList](https://mantine.dev/llms/core-overflow-list.md): Hide items that do not fit in a container and display them as a single collapsed item
- [Overlay](https://mantine.dev/llms/core-overlay.md): Overlays parent element with div element with any color and opacity
- [Pagination](https://mantine.dev/llms/core-pagination.md): Display active page and navigate between multiple pages
- [Paper](https://mantine.dev/llms/core-paper.md): Renders white or dark background depending on color scheme
- [PasswordInput](https://mantine.dev/llms/core-password-input.md): Capture password data from user
- [Pill](https://mantine.dev/llms/core-pill.md): Removable and non-removable tags
- [PillsInput](https://mantine.dev/llms/core-pills-input.md): Base component for custom tags inputs and multi selects
- [PinInput](https://mantine.dev/llms/core-pin-input.md): Capture pin code or one time password from the user
- [Popover](https://mantine.dev/llms/core-popover.md): Display popover section relative to given target element
- [Portal](https://mantine.dev/llms/core-portal.md): Renders component outside of parent element tree
- [Progress](https://mantine.dev/llms/core-progress.md): Give user feedback for status of the task
- [Radio](https://mantine.dev/llms/core-radio.md): Wrapper for input type radio
- [RangeSlider](https://mantine.dev/llms/core-range-slider.md): RangeSlider component
- [Rating](https://mantine.dev/llms/core-rating.md): Pick and display rating
- [RingProgress](https://mantine.dev/llms/core-ring-progress.md): Give user feedback for status of the task with circle diagram
- [RollingNumber](https://mantine.dev/llms/core-rolling-number.md): Animate value changes with rolling digits
- [ScrollArea](https://mantine.dev/llms/core-scroll-area.md): Area with custom scrollbars
- [Scroller](https://mantine.dev/llms/core-scroller.md): Horizontal scroll container with navigation controls
- [SegmentedControl](https://mantine.dev/llms/core-segmented-control.md): A linear set of two or more segments
- [Select](https://mantine.dev/llms/core-select.md): Custom searchable select
- [SemiCircleProgress](https://mantine.dev/llms/core-semi-circle-progress.md): Represent progress with semi circle diagram
- [SimpleGrid](https://mantine.dev/llms/core-simple-grid.md): Responsive grid in which each item takes equal amount of space
- [Skeleton](https://mantine.dev/llms/core-skeleton.md): Indicate content loading state
- [Slider](https://mantine.dev/llms/core-slider.md): Slider component
- [Space](https://mantine.dev/llms/core-space.md): Add horizontal or vertical spacing from theme
- [Splitter](https://mantine.dev/llms/core-splitter.md): Resizable split pane layout
- [Spoiler](https://mantine.dev/llms/core-spoiler.md): Hide long sections of content under a spoiler
- [Stack](https://mantine.dev/llms/core-stack.md): Compose elements and components in a vertical flex container
- [Stepper](https://mantine.dev/llms/core-stepper.md): Display content divided into a steps sequence
- [Switch](https://mantine.dev/llms/core-switch.md): Capture boolean input from user
- [Table](https://mantine.dev/llms/core-table.md): Render table with theme styles
- [TableOfContents](https://mantine.dev/llms/core-table-of-contents.md): Renders a list of headings on the page and tracks current heading visible in the viewport
- [Tabs](https://mantine.dev/llms/core-tabs.md): Switch between different views
- [TagsInput](https://mantine.dev/llms/core-tags-input.md): Capture a list of values from user with free input and suggestions
- [Text](https://mantine.dev/llms/core-text.md): Display text
- [Textarea](https://mantine.dev/llms/core-textarea.md): Autosize or regular textarea
- [TextInput](https://mantine.dev/llms/core-text-input.md): Capture string input from user
- [ThemeIcon](https://mantine.dev/llms/core-theme-icon.md): Render icon inside element with theme colors
- [Timeline](https://mantine.dev/llms/core-timeline.md): Display list of events in chronological order
- [Title](https://mantine.dev/llms/core-title.md): h1-h6 heading
- [Tooltip](https://mantine.dev/llms/core-tooltip.md): Renders tooltip at given element on mouse over or other event
- [Transition](https://mantine.dev/llms/core-transition.md): Animate presence of component with pre-made animations
- [Tree](https://mantine.dev/llms/core-tree.md): Display a Tree structure
- [TreeSelect](https://mantine.dev/llms/core-tree-select.md): Select with hierarchical tree data
- [Typography](https://mantine.dev/llms/core-typography.md): Styles provider for html content
- [UnstyledButton](https://mantine.dev/llms/core-unstyled-button.md): Unstyled polymorphic button
- [VisuallyHidden](https://mantine.dev/llms/core-visually-hidden.md): Hide element visually but keep it accessible for screen readers
## Hooks
- [HooksPackage](https://mantine.dev/llms/hooks-package.md)
- [use-element-size](https://mantine.dev/llms/hooks-use-element-size.md): Returns element width and height and observes changes with ResizeObserver
- [useClickOutside](https://mantine.dev/llms/hooks-use-click-outside.md)
- [useClipboard](https://mantine.dev/llms/hooks-use-clipboard.md)
- [useCollapse](https://mantine.dev/llms/hooks-use-collapse.md)
- [useColorScheme](https://mantine.dev/llms/hooks-use-color-scheme.md)
- [useCounter](https://mantine.dev/llms/hooks-use-counter.md)
- [useDebouncedCallback](https://mantine.dev/llms/hooks-use-debounced-callback.md)
- [useDebouncedState](https://mantine.dev/llms/hooks-use-debounced-state.md)
- [useDebouncedValue](https://mantine.dev/llms/hooks-use-debounced-value.md)
- [useDidUpdate](https://mantine.dev/llms/hooks-use-did-update.md)
- [useDisclosure](https://mantine.dev/llms/hooks-use-disclosure.md)
- [useDocumentTitle](https://mantine.dev/llms/hooks-use-document-title.md)
- [useDocumentVisibility](https://mantine.dev/llms/hooks-use-document-visibility.md)
- [useDrag](https://mantine.dev/llms/hooks-use-drag.md)
- [useEventListener](https://mantine.dev/llms/hooks-use-event-listener.md)
- [useEyeDropper](https://mantine.dev/llms/hooks-use-eye-dropper.md)
- [useFavicon](https://mantine.dev/llms/hooks-use-favicon.md)
- [useFetch](https://mantine.dev/llms/hooks-use-fetch.md)
- [useFileDialog](https://mantine.dev/llms/hooks-use-file-dialog.md)
- [useFloatingWindow](https://mantine.dev/llms/hooks-use-floating-window.md)
- [useFocusReturn](https://mantine.dev/llms/hooks-use-focus-return.md)
- [useFocusTrap](https://mantine.dev/llms/hooks-use-focus-trap.md)
- [useFocusWithin](https://mantine.dev/llms/hooks-use-focus-within.md)
- [useForceUpdate](https://mantine.dev/llms/hooks-use-force-update.md)
- [useFullscreen](https://mantine.dev/llms/hooks-use-fullscreen.md)
- [useHash](https://mantine.dev/llms/hooks-use-hash.md)
- [useHeadroom](https://mantine.dev/llms/hooks-use-headroom.md)
- [useHotkeys](https://mantine.dev/llms/hooks-use-hotkeys.md)
- [useHover](https://mantine.dev/llms/hooks-use-hover.md)
- [useId](https://mantine.dev/llms/hooks-use-id.md)
- [useIdle](https://mantine.dev/llms/hooks-use-idle.md)
- [useInputState](https://mantine.dev/llms/hooks-use-input-state.md)
- [useIntersection](https://mantine.dev/llms/hooks-use-intersection.md)
- [useInterval](https://mantine.dev/llms/hooks-use-interval.md)
- [useInViewport](https://mantine.dev/llms/hooks-use-in-viewport.md)
- [useIsFirstRender](https://mantine.dev/llms/hooks-use-is-first-render.md)
- [useIsomorphicEffect](https://mantine.dev/llms/hooks-use-isomorphic-effect.md)
- [useListState](https://mantine.dev/llms/hooks-use-list-state.md)
- [useLocalStorage](https://mantine.dev/llms/hooks-use-local-storage.md)
- [useLogger](https://mantine.dev/llms/hooks-use-logger.md)
- [useLongPress](https://mantine.dev/llms/hooks-use-long-press.md)
- [useMap](https://mantine.dev/llms/hooks-use-map.md)
- [useMask](https://mantine.dev/llms/hooks-use-mask.md)
- [useMediaQuery](https://mantine.dev/llms/hooks-use-media-query.md)
- [useMergedRef](https://mantine.dev/llms/hooks-use-merged-ref.md)
- [useMounted](https://mantine.dev/llms/hooks-use-mounted.md)
- [useMouse](https://mantine.dev/llms/hooks-use-mouse.md)
- [useMove](https://mantine.dev/llms/hooks-use-move.md)
- [useMutationObserver](https://mantine.dev/llms/hooks-use-mutation-observer.md)
- [useNetwork](https://mantine.dev/llms/hooks-use-network.md)
- [useOrientation](https://mantine.dev/llms/hooks-use-orientation.md)
- [useOs](https://mantine.dev/llms/hooks-use-os.md)
- [usePageLeave](https://mantine.dev/llms/hooks-use-page-leave.md)
- [usePagination](https://mantine.dev/llms/hooks-use-pagination.md)
- [usePrevious](https://mantine.dev/llms/hooks-use-previous.md)
- [useQueue](https://mantine.dev/llms/hooks-use-queue.md)
- [useRadialMove](https://mantine.dev/llms/hooks-use-radial-move.md)
- [useReducedMotion](https://mantine.dev/llms/hooks-use-reduced-motion.md)
- [useResizeObserver](https://mantine.dev/llms/hooks-use-resize-observer.md)
- [useRovingIndex](https://mantine.dev/llms/hooks-use-roving-index.md)
- [useScrollDirection](https://mantine.dev/llms/hooks-use-scroll-direction.md)
- [useScroller](https://mantine.dev/llms/hooks-use-scroller.md)
- [useScrollIntoView](https://mantine.dev/llms/hooks-use-scroll-into-view.md)
- [useScrollSpy](https://mantine.dev/llms/hooks-use-scroll-spy.md)
- [useSelection](https://mantine.dev/llms/hooks-use-selection.md)
- [useSet](https://mantine.dev/llms/hooks-use-set.md)
- [useSetState](https://mantine.dev/llms/hooks-use-set-state.md)
- [useShallowEffect](https://mantine.dev/llms/hooks-use-shallow-effect.md)
- [useSplitter](https://mantine.dev/llms/hooks-use-splitter.md)
- [useStateHistory](https://mantine.dev/llms/hooks-use-state-history.md)
- [useTextSelection](https://mantine.dev/llms/hooks-use-text-selection.md)
- [useThrottledCallback](https://mantine.dev/llms/hooks-use-throttled-callback.md)
- [useThrottledState](https://mantine.dev/llms/hooks-use-throttled-state.md)
- [useThrottledValue](https://mantine.dev/llms/hooks-use-throttled-value.md)
- [useTimeout](https://mantine.dev/llms/hooks-use-timeout.md)
- [useToggle](https://mantine.dev/llms/hooks-use-toggle.md)
- [useUncontrolled](https://mantine.dev/llms/hooks-use-uncontrolled.md)
- [useValidatedState](https://mantine.dev/llms/hooks-use-validated-state.md)
- [useViewportSize](https://mantine.dev/llms/hooks-use-viewport-size.md)
- [useWindowEvent](https://mantine.dev/llms/hooks-use-window-event.md)
- [useWindowScroll](https://mantine.dev/llms/hooks-use-window-scroll.md)
## Form
- [Form context](https://mantine.dev/llms/form-create-form-context.md): Add context support to use-form with createFormContext
- [formActions](https://mantine.dev/llms/form-actions.md)
- [formAllInputs](https://mantine.dev/llms/form-all-inputs.md)
- [formErrors](https://mantine.dev/llms/form-errors.md)
- [formGetInputProps](https://mantine.dev/llms/form-get-input-props.md)
- [formNested](https://mantine.dev/llms/form-nested.md)
- [FormPackage](https://mantine.dev/llms/form-package.md)
- [formRecipes](https://mantine.dev/llms/form-recipes.md)
- [formSchemaValidation](https://mantine.dev/llms/form-schema-validation.md)
- [formStatus](https://mantine.dev/llms/form-status.md)
- [formUncontrolled](https://mantine.dev/llms/form-uncontrolled.md)
- [formValidation](https://mantine.dev/llms/form-validation.md)
- [formValidators](https://mantine.dev/llms/form-validators.md)
- [formValues](https://mantine.dev/llms/form-values.md)
- [use-field](https://mantine.dev/llms/form-use-field.md): use-field hook – manage single field state
- [use-form](https://mantine.dev/llms/form-use-form.md): Manage form state
## Dates
- [Calendar](https://mantine.dev/llms/dates-calendar.md): Base component for custom date pickers
- [DateInput](https://mantine.dev/llms/dates-date-input.md): Free form date input
- [DatePicker](https://mantine.dev/llms/dates-date-picker.md): Inline date, multiple dates and dates range picker
- [DatePickerInput](https://mantine.dev/llms/dates-date-picker-input.md): Date, multiple dates and dates range picker input
- [DateTimePicker](https://mantine.dev/llms/dates-date-time-picker.md): Capture datetime from the user
- [GettingStartedDates](https://mantine.dev/llms/dates-getting-started.md)
- [InlineDateTimePicker](https://mantine.dev/llms/dates-inline-date-time-picker.md): Inline date and time picker with range support
- [MiniCalendar](https://mantine.dev/llms/dates-mini-calendar.md): Compact calendar to display a small number of days in a row
- [MonthPicker](https://mantine.dev/llms/dates-month-picker.md): Inline month, multiple months and months range picker
- [MonthPickerInput](https://mantine.dev/llms/dates-month-picker-input.md): Month, multiple months and months range picker input
- [TimeGrid](https://mantine.dev/llms/dates-time-grid.md): Captures time value from the user with a predefined set of options
- [TimeInput](https://mantine.dev/llms/dates-time-input.md): Capture time from the user
- [TimePicker](https://mantine.dev/llms/dates-time-picker.md): Captures time value from the user
- [TimeValue](https://mantine.dev/llms/dates-time-value.md): Display a formatted time value
- [YearPicker](https://mantine.dev/llms/dates-year-picker.md): Inline year, multiple years and years range picker
- [YearPickerInput](https://mantine.dev/llms/dates-year-picker-input.md): Inline year, multiple years and years range picker
## Charts
- [AreaChart](https://mantine.dev/llms/charts-area-chart.md): Area chart component with stacked, percent and split variants
- [BarChart](https://mantine.dev/llms/charts-bar-chart.md): Bar chart component with stacked and percent variants
- [BarsList](https://mantine.dev/llms/charts-bars-list.md): Display a list of bars with names and values
- [BubbleChart](https://mantine.dev/llms/charts-bubble-chart.md): Bubble chart component
- [BulletChart](https://mantine.dev/llms/charts-bullet-chart.md): Compact KPI chart with value, target, and qualitative ranges
- [CompositeChart](https://mantine.dev/llms/charts-composite-chart.md): Composed chart with support for Area, Bar and Line charts
- [DonutChart](https://mantine.dev/llms/charts-donut-chart.md): Donut chart component
- [FunnelChart](https://mantine.dev/llms/charts-funnel-chart.md): Funnel chart component
- [GettingStartedCharts](https://mantine.dev/llms/charts-getting-started.md)
- [Heatmap](https://mantine.dev/llms/charts-heatmap.md): Heatmap chart component
- [LineChart](https://mantine.dev/llms/charts-line-chart.md): Line chart component
- [PieChart](https://mantine.dev/llms/charts-pie-chart.md): Pie chart component
- [RadarChart](https://mantine.dev/llms/charts-radar-chart.md): Radar chart component
- [RadialBarChart](https://mantine.dev/llms/charts-radial-bar-chart.md): Radial bar chart component
- [SankeyChart](https://mantine.dev/llms/charts-sankey-chart.md): Sankey diagram component
- [ScatterChart](https://mantine.dev/llms/charts-scatter-chart.md): Scatter chart component
- [Sparkline](https://mantine.dev/llms/charts-sparkline.md): Simplified area chart to show trends
- [SunburstChart](https://mantine.dev/llms/charts-sunburst-chart.md): Sunburst chart component for hierarchical data
- [Treemap](https://mantine.dev/llms/charts-treemap.md): Treemap chart component
## Schedule
- [AgendaView](https://mantine.dev/llms/schedule-agenda-view.md): Vertical list view of events grouped by date
- [DayView](https://mantine.dev/llms/schedule-day-view.md): Standalone schedule day view component
- [GettingStartedSchedule](https://mantine.dev/llms/schedule-getting-started.md)
- [MobileMonthView](https://mantine.dev/llms/schedule-mobile-month-view.md): Mobile-optimized schedule month view component
- [MonthView](https://mantine.dev/llms/schedule-month-view.md): Standalone schedule month view component
- [RecurringEventsSchedule](https://mantine.dev/llms/schedule-recurring-events.md)
- [ResourcesDayView](https://mantine.dev/llms/schedule-resources-day-view.md): Day view with resource columns for scheduling across resources
- [ResourcesMonthView](https://mantine.dev/llms/schedule-resources-month-view.md): Month view with resource lanes for scheduling across resources
- [ResourcesSchedule](https://mantine.dev/llms/schedule-resources-schedule.md): Schedule wrapper component that combines resource day, week and month views
- [ResourcesWeekView](https://mantine.dev/llms/schedule-resources-week-view.md): Week view with resource rows for scheduling across resources
- [Schedule](https://mantine.dev/llms/schedule-schedule.md): Schedule component with day, week, month and year views
- [ScheduleEventsData](https://mantine.dev/llms/schedule-events-data.md)
- [WeekView](https://mantine.dev/llms/schedule-week-view.md): Standalone schedule week view component
- [YearView](https://mantine.dev/llms/schedule-year-view.md): Standalone schedule year view component
## Guides
- [ControlledVsUncontrolled](https://mantine.dev/llms/guides-controlled-vs-uncontrolled.md)
- [CustomComponents](https://mantine.dev/llms/guides-custom-components.md)
- [EightToNine](https://mantine.dev/llms/guides-8x-to-9x.md)
- [FunctionsReference](https://mantine.dev/llms/guides-functions-reference.md)
- [Gatsby](https://mantine.dev/llms/guides-gatsby.md)
- [Icons](https://mantine.dev/llms/guides-icons.md)
- [JavaScript](https://mantine.dev/llms/guides-javascript.md)
- [Jest](https://mantine.dev/llms/guides-jest.md)
- [LLMDocumentation](https://mantine.dev/llms/guides-llms.md)
- [NextJs](https://mantine.dev/llms/guides-next.md)
- [Polymorphic](https://mantine.dev/llms/guides-polymorphic.md)
- [ReactRouter](https://mantine.dev/llms/guides-react-router.md)
- [Redwood](https://mantine.dev/llms/guides-redwood.md)
- [SevenToEight](https://mantine.dev/llms/guides-7x-to-8x.md)
- [SixToSeven](https://mantine.dev/llms/guides-6x-to-7x.md)
- [Storybook](https://mantine.dev/llms/guides-storybook.md)
- [Tiptap3Migration](https://mantine.dev/llms/guides-tiptap-3-migration.md)
- [TypeScript](https://mantine.dev/llms/guides-typescript.md)
- [Vite](https://mantine.dev/llms/guides-vite.md)
- [Vitest](https://mantine.dev/llms/guides-vitest.md)
## Theming
- [Colors](https://mantine.dev/llms/theming-colors.md)
- [ColorSchemes](https://mantine.dev/llms/theming-color-schemes.md)
- [DefaultProps](https://mantine.dev/llms/theming-default-props.md)
- [MantineProvider](https://mantine.dev/llms/theming-mantine-provider.md)
- [ThemeObject](https://mantine.dev/llms/theming-theme-object.md)
- [Typography](https://mantine.dev/llms/theming-typography.md): Styles provider for html content
## Styles
- [ColorFunctions](https://mantine.dev/llms/styles-color-functions.md)
- [CSSFilesList](https://mantine.dev/llms/styles-css-files-list.md)
- [CSSModules](https://mantine.dev/llms/styles-css-modules.md)
- [CssVariables](https://mantine.dev/llms/styles-css-variables.md)
- [CSSVariablesList](https://mantine.dev/llms/styles-css-variables-list.md)
- [DataAttributes](https://mantine.dev/llms/styles-data-attributes.md)
- [Emotion](https://mantine.dev/llms/styles-emotion.md)
- [GlobalStyles](https://mantine.dev/llms/styles-global-styles.md)
- [MantineStyles](https://mantine.dev/llms/styles-mantine-styles.md)
- [PostCSSPreset](https://mantine.dev/llms/styles-postcss-preset.md)
- [Rem](https://mantine.dev/llms/styles-rem.md)
- [ResponsiveStyles](https://mantine.dev/llms/styles-responsive.md)
- [Rtl](https://mantine.dev/llms/styles-rtl.md)
- [StyleProp](https://mantine.dev/llms/styles-style.md)
- [StyleProps](https://mantine.dev/llms/styles-style-props.md)
- [StylesApi](https://mantine.dev/llms/styles-styles-api.md)
- [StylesOverview](https://mantine.dev/llms/styles-styles-overview.md)
- [StylesPerformance](https://mantine.dev/llms/styles-styles-performance.md)
- [UnstyledComponents](https://mantine.dev/llms/styles-unstyled.md)
- [UsageWithSass](https://mantine.dev/llms/styles-sass.md)
- [VanillaExtract](https://mantine.dev/llms/styles-vanilla-extract.md)
- [VariantsAndSizes](https://mantine.dev/llms/styles-variants-sizes.md)
## X
- [Carousel](https://mantine.dev/llms/x-carousel.md): Embla based carousel component
- [CodeHighlight](https://mantine.dev/llms/x-code-highlight.md): Highlight code with shiki or highlight.js
- [Dropzone](https://mantine.dev/llms/x-dropzone.md): Capture files from user with drag and drop
- [ExtensionsPackage](https://mantine.dev/llms/x-extensions.md)
- [Modals manager](https://mantine.dev/llms/x-modals.md): Centralized modals manager with option to handle state of multi-step modals
- [NavigationProgress](https://mantine.dev/llms/x-nprogress.md): Navigation progress bar
- [Notifications system](https://mantine.dev/llms/x-notifications.md): Mantine notifications system
- [Rich text editor](https://mantine.dev/llms/x-tiptap.md): Tiptap based rich text editor
- [Spotlight](https://mantine.dev/llms/x-spotlight.md): Command center for your application
## Other
- [About](https://mantine.dev/llms/about.md)
- [AllReleases](https://mantine.dev/llms/changelog-all-releases.md)
- [BrowserSupport](https://mantine.dev/llms/browser-support.md)
- [Changelog700](https://mantine.dev/llms/changelog-7-0-0.md)
- [Changelog710](https://mantine.dev/llms/changelog-7-1-0.md)
- [Changelog7100](https://mantine.dev/llms/changelog-7-10-0.md)
- [Changelog7110](https://mantine.dev/llms/changelog-7-11-0.md)
- [Changelog7120](https://mantine.dev/llms/changelog-7-12-0.md)
- [Changelog7130](https://mantine.dev/llms/changelog-7-13-0.md)
- [Changelog7140](https://mantine.dev/llms/changelog-7-14-0.md)
- [Changelog7150](https://mantine.dev/llms/changelog-7-15-0.md)
- [Changelog7160](https://mantine.dev/llms/changelog-7-16-0.md)
- [Changelog7170](https://mantine.dev/llms/changelog-7-17-0.md)
- [Changelog720](https://mantine.dev/llms/changelog-7-2-0.md)
- [Changelog730](https://mantine.dev/llms/changelog-7-3-0.md)
- [Changelog740](https://mantine.dev/llms/changelog-7-4-0.md)
- [Changelog750](https://mantine.dev/llms/changelog-7-5-0.md)
- [Changelog760](https://mantine.dev/llms/changelog-7-6-0.md)
- [Changelog770](https://mantine.dev/llms/changelog-7-7-0.md)
- [Changelog780](https://mantine.dev/llms/changelog-7-8-0.md)
- [Changelog790](https://mantine.dev/llms/changelog-7-9-0.md)
- [Changelog800](https://mantine.dev/llms/changelog-8-0-0.md)
- [Changelog810](https://mantine.dev/llms/changelog-8-1-0.md)
- [Changelog820](https://mantine.dev/llms/changelog-8-2-0.md)
- [Changelog830](https://mantine.dev/llms/changelog-8-3-0.md)
- [Changelog900](https://mantine.dev/llms/changelog-9-0-0.md)
- [Changelog910](https://mantine.dev/llms/changelog-9-1-0.md)
- [Changelog920](https://mantine.dev/llms/changelog-9-2-0.md)
- [Changelog930](https://mantine.dev/llms/changelog-9-3-0.md)
- [Changelog940](https://mantine.dev/llms/changelog-9-4-0.md)
- [Changelog950](https://mantine.dev/llms/changelog-9-5-0.md)
- [Contribute](https://mantine.dev/llms/contribute.md)
- [GettingStarted](https://mantine.dev/llms/getting-started.md)
- [NotFound](https://mantine.dev/llms/404.md)
- [OxcConfig](https://mantine.dev/llms/oxc-config-mantine.md)
- [PreviousChangelogs](https://mantine.dev/llms/changelog-previous-versions.md)
- [Support](https://mantine.dev/llms/support.md)
+16 -10
View File
@@ -6,28 +6,34 @@ product
## Users
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work.
Homarr is for self-hosters and administrators who assemble dashboards for services across home labs and private infrastructure. They need dense, reliable controls and status at a glance, while retaining enough guidance to configure advanced features without hiding how they work.
## Product Purpose
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service.
Homarr provides one place to organize, observe, and operate self-hosted services. Success means users can build useful dashboards quickly, understand their state, recover from configuration errors, act on services, and reshape their workspace without repeatedly leaving the board. Boards must load immediately, stay visually stable, and keep widgets legible across screen sizes.
## Brand Personality
Direct, capable, and approachable. Copy should be concise and technical when precision matters, while the interface should remain familiar to users who are not developers.
Direct, capable, precise, and approachable. Homarr should feel calm under load, be truthful about system state, and reward users who refine details.
## Anti-references
Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, hidden configuration magic, inconsistent controls, excessive modal usage, and interfaces that expose implementation complexity before the user needs it.
- Decorative dashboards, oversized metric cards, and motion without state meaning.
- Wizard flows that fight ordinary scrolling or rigid layouts that force one workflow.
- Hidden configuration magic and implementation complexity exposed before it is needed.
- Clever custom controls where familiar platform or Mantine interactions already work.
## Design Principles
1. Keep the user in control by making configuration and consequences visible.
2. Prefer familiar product patterns and progressive disclosure over custom interaction concepts.
3. Make simple tasks short without limiting advanced use cases.
4. Isolate failures so one service or widget cannot disrupt the dashboard.
5. Use the existing Mantine design language consistently across management and board surfaces.
1. Keep configuration, consequences, and system state visible.
2. Make simple tasks short without limiting advanced workflows.
3. Preserve expert depth while prioritizing urgent information at small sizes.
4. Reveal dense workspaces progressively through a consistent advanced focus mode.
5. Isolate failures and show loading, stale, partial, empty, and failed states truthfully.
6. Use the existing Mantine design language consistently across management and board surfaces.
7. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback.
8. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible.
## Accessibility & Inclusion
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, and actionable error messages.
Target WCAG 2.2 AA. Every pointer interaction needs a keyboard and coarse-pointer path. Support screen readers, visible and restored focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations.
+263
View File
@@ -0,0 +1,263 @@
# PR #6502 widget-modernization ultra-review
## Executive result
**Merge recommendation: changes required.** No P0 was found, but the current branch still has five P1 issues: a pre-existing Custom API authorization/redirect weakness exposed by this subsystem review, an inaccessible manual advanced-focus interaction, a likely fixed-position/transform geometry defect, systemic false empty states for failed queries, and unvalidated integration-provided navigation URLs. Eight P2 issues cover raw error disclosure, unsafe runtime-ref interfaces, localization, responsive/theming drift, test coverage, and inaccurate PR copy.
Audit health (Impeccable rubric): accessibility 1/4, performance 2/4, responsive design 2/4, theming 2/4, anti-patterns 3/4; **10/20 (Acceptable, significant work required)**. The UI does not look generically AI-generated; its problem is inconsistent technical behavior beneath an otherwise coherent Mantine vocabulary.
## Reviewed revision and environment
| Item | Value |
| ---------------------------- | ---------------------------------------------------------------------------------------------- |
| PR | #6502, `feat: modernize dashboard widgets` |
| Reviewed head | `96e92485a5fd45f3648cfac0453e614779c414ba` |
| Base branch / fetched base | `dev` / `d58a47b776a55d04dee2220f59d072b9af8bbed3` |
| Merge base | `d58a47b776a55d04dee2220f59d072b9af8bbed3` |
| Diff | 359 files, +15,721/-4,478 |
| PR state | open, non-draft, `BLOCKED`; no approval decision |
| Hosted checks at audit start | Fast gate, Container and E2E, CodeQL, JS/TS analysis, Actions analysis, title, auto-fix: green |
| Inline threads | 34 total, 7 unresolved; only 2 remain substantively valid |
| Mantine | repository lock: `@mantine/core`, hooks, charts and dates 9.4.1 |
| Local tools | Node 24.16.0 and pnpm 9.5.0; repository requires Node >=24.18.0 and pnpm >=11.15.1 |
The former red baseline `7b7045202d289b7d83946befcee5d2845ea4a706` had a green Fast gate and a failed Container/E2E run (`30748255760`, job `91498308523`). The latest head has a green Container/E2E job (`30756067198`, job `91519039504`). This proves the current suite passed once; it does not satisfy the requested three-run flake characterization.
## PR-description traceability
| Claim | Result | Evidence / correction |
| --------------------------------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| “all 54 widget renderers with compact and advanced modes” | **Inaccurate** | 54 `WidgetKind` values exist. `displayMode="advanced"` reaches 53; `app` is deliberately excluded in `item-content.tsx:51` and documented as compact-only. Change copy to “53 widget renderers; app tiles remain compact,” or implement app advanced mode. |
| advanced focus: hover, keyboard, touch, geometry | **Partial** | Entry paths, Escape ownership, reduced motion, close focus, touch button and zero-dimension guards exist. WID-002/003 remain. |
| richer widget context actions | **Partial** | `Menu.ContextMenu` is a good Mantine choice and query scope is item-aware. Dispatch still crosses `any`; Custom API action authorization is not represented. |
| standardized query identity/cache/status | **Partial** | Query keys, matchers, runtime capture, recovery and tests exist. Thirty-nine of 53 renderer query calls do not read errors; runtime ref registration is render-time mutable state. |
| safe polling | **Mostly confirmed** | defaults are limited to `widget` namespace; Custom API resume immediately refetches; per-widget intervals exist. State/ref typing still needs redesign. |
| partial-failure rendering | **Partial** | settlement helper and several routers are strong; many renderer-level total failures still appear empty/disconnected. |
| bounded integration responses | **Mostly confirmed** | common bounded reader, Custom API 2 MiB, timetable 512 KiB, Traefik bounds and Beszel queue bounds exist. Custom action execution remains outside the hardened fetch path. |
| timetable hardening | **Confirmed** | board/item permission checks, saved-value comparison, public-address resolution, private ranges, per-request DNS re-resolution, pinned dispatcher, redirect rejection, timeouts, response bounds and schemas are present. |
| precise placement | **Mostly confirmed** | mouse, touch tolerance, keyboard, occupied-cell validation, dynamic bounds, nearest fit and immediate persistence exist. Failure/race/live responsive coverage remains insufficient. |
| widget catalog documentation | **Partial** | all kinds have pages and shared interactions; wording overclaims focus behavior and validation counts are stale. |
| validation: 40 files/162 tests, 6/6 E2E | **Stale** | current diff contains 57 changed unit-spec files (including three E2E specs); repository has 42 widget spec files and six E2E specs. Update from the exact final run. |
| independent reviews “pass” | **Not supported** | unresolved valid findings, current P1s, and unavailable live QA contradict an unqualified pass. |
## Mantine suitability ledger
Legend: **U** already used appropriately; **C** consolidation/replacement candidate; **R** relevant but current implementation can stay; **N** not relevant to widget runtime; **D** dependency/version addition. Exact 9.4.1 package archives were inspected in `/tmp`; live docs/Context7 include 9.5.0.
### Core catalog
- **U:** ActionIcon, Alert, Anchor, Avatar, Badge, Box, Button, Card, Center, Code, Divider, Flex, Grid, Group, Image, Loader, Menu (`Menu.ContextMenu`), Overlay, Paper, Popover, Portal, Progress, RingProgress, ScrollArea, SimpleGrid, Skeleton, Stack, Table, Tabs, Text, ThemeIcon, Title, Tooltip, UnstyledButton.
- **C:** Accordion (dense integration sections), DataList (semantic label/value stats), EmptyState (replace bespoke empty state), FocusTrap and Modal compound API (WID-002), LoadingOverlay (background refetch without deleting content), NumberFormatter, OverflowList/Scroller (responsive action and chip rows), Table.ScrollContainer, Timeline (activity/feed histories), VisuallyHidden and Kbd, CloseButton, Dialog/HoverCard/Notification where persistent errors/actions need semantics.
- **R:** Affix, AppShell, AspectRatio, BackgroundImage, Breadcrumbs, Burger, Checkbox, Chip, Collapse, ColorInput/ColorPicker/ColorSwatch/AlphaSlider/AngleSlider/HueSlider, Combobox/ComboboxPopover, Container, CopyButton, Drawer, Fieldset, FileButton/FileInput, FloatingIndicator/FloatingWindow, Highlight, Indicator, Input/JsonInput, List, Mark, Marquee, MaskInput, Menubar, MultiSelect/NativeSelect/NumberInput/PasswordInput/PillsInput/PinInput/Radio/RangeSlider/Rating/SegmentedControl/Select/Slider/Switch/TagsInput/Textarea/TextInput, Pagination, Pill, SemiCircleProgress, Space, Splitter, Spoiler, Stepper, Transition, Tree/TreeSelect, Typography. Use only where an existing widget requirement matches; do not churn working layouts.
- **N:** Alpha/color/angle primitives outside configuration forms, AppShell/navigation primitives, TableOfContents, CorePackage documentation.
- **D (9.5, not 9.4.1):** Cascader. **RollingNumber and Splitter are present in 9.4.1.**
### Hooks catalog
- **C:** `useFocusReturn`, `useFocusTrap`, `useReducedMotion`, `useRovingIndex`, `useLongPress`, `useScroller`, `useElementSize`, `useViewportSize`, `useHotkeys`, `useMediaQuery`, `useResizeObserver`.
- **U/R:** focus-within, hover, disclosure, click-outside, merged-ref, interval/timeout, local-storage, event/window listeners, throttled/debounced values and list/set/map/state helpers are available in 9.4.1 and should replace local mechanics only when behavior and tests improve.
- **N:** document title/favicon/hash/headroom/page-leave/OS/eye-dropper/fullscreen/network/orientation and other page-level utilities have no direct widget-remediation need. Every hook listed under `## Hooks` in `MANTINE.md` is covered by this rule; no hook requires 9.5 for the recommendations above.
### Form, dates, charts, schedule, extensions, theme and styles
- **Form:** existing `@homarr/form` should remain the abstraction over all cataloged form APIs. No widget-runtime replacement; use current validation/status APIs for widget editors (**R**).
- **Dates:** all catalog entries are in installed 9.4.1 dates. `Calendar`, `MiniCalendar`, and `TimeValue` are candidates; other picker/input entries are editor-only (**R**). Importing dates directly into `@homarr/widgets` requires adding that workspace dependency (**D**).
- **Charts:** 9.4.1 exports Area, Bar, BarsList, Bubble, Composite, Donut, Funnel, Heatmap, Line, Pie, Radar, RadialBar, Sankey, Scatter, Sparkline and Treemap (**C/R**). Prefer Sparkline for tiny trends, BarsList for ranked values, Heatmap for calendar/activity density, Composite for mixed analytics. **BulletChart and SunburstChart are 9.5-only (D)**. Replacement requires accessibility and bundle comparison, not blanket Recharts removal.
- **Schedule:** all `AgendaView`, day/week/month/year/resource views and Schedule pages require new `@mantine/schedule` dependency (**D**). AgendaView is the only plausible calendar-widget candidate; its weight and semantics must beat the existing grouped list before adoption.
- **Guides/FAQ/Other:** server components, portal testing, scroll lock, accessibility, nested popovers, zoom, responsive styling and version changelogs are relevant references (**R**); framework/setup/contribution/old migration entries are **N**. The 9.5 changelog is informational only.
- **Theming/styles:** Colors, ColorSchemes, DefaultProps, MantineProvider, ThemeObject, CSS variables, data attributes, CSS Modules, responsive/RTL/style props/Styles API/performance are **U/R**. WID-009 requires shared layer/color tokens. Emotion, Sass, Vanilla Extract and unrelated global setup are **N**.
- **Extensions:** existing modals, notifications, spotlight and rich-text abstractions are **U**; Carousel could consolidate Immich only after behavior parity; Dropzone/CodeHighlight/NProgress are **N** for this audit.
## Complete 54-widget coverage matrix
“Test” means a focused widget-local spec exists, not that all required states are covered. Every row still requires the common contract suite in WID-013.
| WidgetKind | Family | Advanced | Focused test | Principal audit gap |
| --------------------------- | ----------------------- | ----------- | ------------ | ---------------------------------------------------------- |
| clock | basic | yes | no | query error, locale-format options |
| weather | basic/feed | yes | yes | query error; dayjs locale/time patterns |
| app | basic/link | **no** | no | PR wording; URL contract |
| iframe | custom content | yes | yes | manual focus/iframe trapping; URL privacy thread now fixed |
| video | custom content | yes | no | media/focus/live behavior |
| notebook | notes | yes (inner) | yes | editor focus inside manual surface |
| anchorNote | notes | yes | no | query error, save/error regression |
| dnsHoleSummary | network | yes | no | total query error, hard-coded chart colors |
| dnsHoleControls | network/security | yes | no | total error and permission matrix |
| smartHome-entityState | smart home | yes | no | raw state in aria; action permissions |
| smartHome-executeAutomation | smart home | yes | no | mutation/permission/edit-mode E2E |
| stockPrice | analytics | yes | yes | total error |
| mediaServer | media | yes | no | total error and table a11y |
| calendar | calendar | yes | yes | total error; fixed HH:mm; schedule tradeoff |
| downloads | media | yes | yes | four-state/component coverage, arbitrary z-index |
| mediaRequests-requestList | media | yes | no | total error; external URLs/actions |
| mediaRequests-requestStats | media | yes | no | total error; links/colors |
| mediaTranscoding | media | yes | yes | total error and responsive table |
| mediaMissing | media | yes | yes | total error and external URL |
| minecraftServerStatus | infrastructure | yes | no | total error/malformed payload |
| networkControllerSummary | networking | yes | no | total error; WAN/unknown localization |
| networkControllerStatus | networking | yes | no | responsive rule duplication |
| rssFeed | feed | yes | yes | strong stale/partial state; locale relative time |
| bookmarks | basic/link | yes | yes | recomputation; URL contract |
| indexerManager | media | yes | no | total error; runtime action ref |
| healthMonitoring | infrastructure | yes | yes | integration query error |
| releases | infrastructure/feed | yes | yes | secret/item authorization needs E2E |
| mediaReleases | media | yes | no | total error; external URLs |
| dockerContainers | infrastructure | yes | yes | total error; z-index/table semantics |
| firewall | networking/security | yes | yes | partial-query matrix; loose router generics |
| notifications | infrastructure | yes | no | total error; untrusted href |
| systemResources | infrastructure | yes | yes | total error; previous network thread fixed |
| coolify | infrastructure | yes | yes | total error; URL validation |
| systemDisks | infrastructure | yes | yes | total error |
| timetable | calendar | yes | no | backend strong; component locale/live tests absent |
| immich-serverStats | media | yes | yes | two ignored query errors |
| immich-albumCarousel | media | yes | yes (query) | query error and nested overlay/focus E2E |
| paperlessNgx | analytics | yes | no | former error thread fixed, no regression spec |
| patchmon | infrastructure | yes | yes | total error |
| bazarr | media | yes | yes | total error |
| tracearr | analytics | yes | yes | total error |
| speedtestTracker | analytics | yes | yes | total error |
| uptimeKuma | infrastructure | yes | yes | total error |
| audioStats | media | yes | yes | good dual-error handling; partial-state tests needed |
| umami | analytics | yes | no | runtime query registration; chart a11y |
| vpn | networking/security | yes | no | total error; status/permission matrix |
| archiveTeamWarrior | infrastructure | yes | yes | suspense path; docs present |
| ups | infrastructure | yes | no | total error |
| beszelSystemTable | monitoring | yes | yes | nested chart/focus and live subscription |
| beszelSystemGrid | monitoring | yes | no | live/error UI; no focused renderer test |
| beszelAlerts | monitoring | yes | no | partial error and status semantics |
| beszelSystemStats | monitoring | yes | yes | runtime refs, locale tooltip |
| traefik | networking | yes | no | total error; bounded backend positive |
| customApi | custom content/security | yes | yes | WID-001/007/008; polling resume fixed |
## Severity-ranked findings
### WID-001 — P1 confirmed, pre-existing: Custom API actions lack resource authorization and hardened fetch policy
- **Location/claim:** `packages/api/src/router/custom-widget/custom-widget-router.ts:57-102,392-451`; “harden Custom API” and action-permission semantics.
- **Evidence/reproduction:** any authenticated account can call `customWidget.all`, learn every definition ID and URL, then call `customWidget.execute({definitionId})`. The mutation verifies neither board nor item nor an action permission. It decrypts administrator-provided secrets, uses `new URL()` only, and `redirect: "follow"`. The PR hardened `widget.customApi.getData`, but not this sibling execution path.
- **Impact/rule:** unauthorized side effects against internal services and authenticated redirect chains; broken object-level authorization. A view-only account can invoke administrator-created controls outside any board context.
- **Correction:** require `{boardId,itemId,definitionId}`; load the item and verify it is `customApi`, belongs to the board, references the definition, and the caller has an explicit execute policy. Until that policy exists, restrict to admin. Share `validateCustomApiUrl`, timeout, redirect rejection, bounded disposal and redacted errors with getData. Compatibility: old direct callers must send item/board context.
- **Tests:** procedure tests for anonymous, unrelated authenticated, view-only, modifier, admin, mismatched item/definition, disabled definition, redirects, credential URL, timeout and secret non-disclosure; E2E action button across roles.
### WID-002 — P1 confirmed, PR-introduced: manual advanced focus is not an accessible modal
- **Location:** `advanced-focus/context.tsx:194-209`, `items/item-content.tsx:113-189`.
- **Evidence:** manual mode renders an Overlay plus a `role="region"` Card. There is no FocusTrap, `aria-modal`, background inertness, scroll lock or backdrop dismissal. Only the close button is initially focused; Tab can leave the surface and activate dimmed board controls.
- **Impact/rule:** keyboard/screen-reader users lose the interaction boundary; WCAG 2.1.2, 2.4.3 and ARIA dialog pattern. Iframes/editors make escape especially likely.
- **Correction/Mantine:** render manual mode through `Modal.Root` compound parts (or ModalBase), using its portal, FocusTrap, scroll lock, Escape/return-focus and semantic content. Preserve preview mode as non-modal. Configure nested overlays and `removeScrollProps`; keep a 44px `Modal.CloseButton` and explicit title via VisuallyHidden if needed.
- **Tests:** RTL portal/focus-cycle/return tests; Playwright Tab/Shift+Tab, nested Menu/Select/Popover/editor/iframe, backdrop and every close path; reduced motion and 200% zoom.
### WID-003 — P1 probable, PR-introduced: fixed advanced surface remains under transformed Gridstack ancestry
- **Location:** `advanced-focus.module.css:35-42`; Card remains inside the Gridstack item while only the backdrop is portaled.
- **Evidence:** CSS fixed descendants establish their containing block at a transformed ancestor. Gridstack uses transforms for positioned items, so viewport-derived `left/top` can be interpreted relative to the item on affected layouts. A Portal wrapper exists only for Overlay.
- **Impact:** off-screen, clipped or incorrectly scaled focus surface across breakpoints/remounts.
- **Correction:** portal the focused surface itself (naturally achieved by WID-002) and calculate geometry in the same viewport coordinate system. Do not compensate with transform-specific offsets.
- **Tests:** real Gridstack at every layout/viewport, scrolled page, header, dynamic section and resize/remount; assert bounding rect within viewport and finite scale.
### WID-004 — P1 confirmed, regression-exposed/systemic: query failures still become valid empty/disconnected UI
- **Location:** 39 of 53 renderer query calls do not read `error`, including bookmarks, weather, calendar, media server, system resources, Coolify, DNS Hole, Tracearr, notifications, Immich, VPN and others listed in the matrix. Query defaults do not set `throwOnError` (`trpc.tsx:69-93`).
- **Evidence:** TanStack Query returns `data=undefined` on total failure. Components default to `[]`, `null`, or empty branches, so the board ErrorBoundary is never reached. Network Controller and Paperless were fixed, proving the pattern; it was not completed.
- **Impact:** operators are told “empty” or “disconnected” when monitoring failed, violating the product’s truthful-state requirement.
- **Correction/Mantine:** introduce a shared `WidgetQueryState` contract distinguishing initial Skeleton, background LoadingOverlay, stale Alert, partial warning, true EmptyState and terminal error. Either explicitly throw only when no usable cached data or render a shared error Alert. Preserve stale data.
- **Tests:** parameterized contract tests for every queried widget: initial, success-empty, total failure, stale+failure, partial failure, refetch, remount/persisted cache and sibling isolation.
### WID-005 — P1 confirmed, PR-introduced/systemic: integration-provided links bypass a shared URL policy
- **Location:** examples include `media-missing/component.tsx:291`, `notifications/component.tsx:77`, media requests list/stats, Coolify resource/server links, releases, bookmarks and iframe. Only RSS and custom JSX visibly use explicit protocol sanitizers.
- **Evidence:** dynamic strings are assigned directly to `href`/new-tab controls. Integration APIs and remote feeds are trust boundaries; no renderer-wide `http/https` guard is enforced.
- **Impact:** unsafe schemes, credential-bearing URLs, internal detail disclosure and inconsistent opener behavior.
- **Correction:** add one typed application URL helper returning a sanitized URL/undefined, with allowed schemes per use case, credential stripping/rejection, consistent `noopener noreferrer`, and disabled non-link presentation. Validate again server-side for action endpoints.
- **Tests:** `javascript:`, `data:`, malformed, credential, relative, http/https and missing URLs for each shared link primitive.
### WID-006 — P2 confirmed, PR-introduced: runtime query/action state mutates untyped refs during render
- **Location:** `definition.ts:121-162`; runtime registration calls in media missing/transcoding, audio, calendar, Umami, Beszel and Immich; `widget-context-menu.tsx:157-174`.
- **Evidence:** `setWidgetRuntimeQueries` mutates a shared object during render; concurrent/aborted renders can publish uncommitted query identity. Action dispatch casts the definition to `Record<string,unknown>` and `any`, then casts its result back.
- **Correction:** make definition generics preserve kind/options/action types end-to-end; replace stringly opaque ref slots with a typed per-item registration API updated in a committed effect (or use `useSyncExternalStore`). Separate query registration from imperative actions.
- **Tests:** StrictMode, interrupted/remount, options change and cleanup; compile-time `tsd`/type assertions rejecting wrong action props.
### WID-007 — P2 confirmed, regression-exposed: raw backend errors are exposed to users and assistive text
- **Location:** Custom API UI `component.tsx:561-576`; API `custom-api.ts:79-85`; several partial fallbacks use `error.message`.
- **Impact:** internal hostnames, request paths and implementation details can appear in tooltips/aria labels and logs.
- **Correction/Mantine:** return stable error codes plus redacted public messages; keep detailed causes server-side with correlation IDs. Render localized Alert/Notification details, never raw throwable text.
- **Tests:** errors containing credentials, query strings, private hosts and multiline backend bodies must not reach tRPC payload, DOM, accessibility tree or notifications.
### WID-008 — P2 confirmed, PR-introduced/regression-exposed: locale handling remains incomplete
- **Locations:** Beszel chart/tooltip dayjs formats, calendar event `HH:mm`, downloads `YYYY-MM-DD HH:mm`, timetable `HH:mm:ss`, weather day/month/time formats; `_inputs/widget-location-input.tsx:199` hardcodes `Intl.NumberFormat("en")`.
- **Also:** `beszel/_shared/disk-usage.tsx:41`, stats loading aria label, Network Controller `WAN`/`unknown` are English/raw accessible strings.
- **Correction:** centralize app-locale date/time/number/duration/status helpers; load canonical dayjs locale only at the boundary; use localized translation keys for aria/status. NumberFormatter is suitable for simple display but application helpers should own locale.
- **Tests:** at least `en-US`, `fr-FR`, `de-DE`, RTL locale, 12/24-hour preference and long translations, including accessibility names.
### WID-009 — P2 confirmed: layer and color tokens are inconsistent
- **Locations:** downloads z-index 1000 and rgba row colors; item title z-index 4; item menu/dynamic menu 10; media server white rgba; Indexer Manager hex colors; DNS Hole rgba palette; Umami hex tick colors.
- **Correction:** define shared semantic layer tokens relative to Mantine CSS z-index variables and semantic status/chart tokens respecting light/dark/forced colors. Do not replace intentional service branding.
- **Tests:** light/dark/forced-colors with nested overlays and tooltip ordering.
### WID-010 — P2 confirmed: responsive decisions are duplicated and under-tested
- **Location:** Network Controller Status `component.tsx:44,59-78` (open valid thread); Bookmark `component.tsx:94` recomputation (valid but low-impact thread); similar width/height literals across renderers.
- **Correction:** small typed breakpoint lookup helpers per family, memoized only for genuinely expensive layout. Prefer container-query Grid/SimpleGrid where structural. Do not cargo-cult `useMemo` for a cheap pure calculation; benchmark Bookmark before accepting the review suggestion.
- **Tests:** boundary tables for minimum/typical/wide/short/advanced sizes and 200% zoom.
### WID-011 — P2 confirmed: PR/docs overstate behavior and validation
- **Location:** PR body, board/getting-started copy “advanced preview in place,” validation numbers, and unqualified review pass.
- **Correction:** state 53 advanced renderers plus compact-only app; call manual focus an overlay only after WID-002/003; publish exact final-SHA check/test counts and disclose unsupported/live-untested states.
### WID-012 — P2 confirmed: focused automated coverage is far below the 54-widget/data-state claim
- **Evidence:** 42 widget-local spec files are helper-heavy; 20+ renderers have no focused spec. Six E2E files exist, but only board interactions and Custom JSX directly exercise this modernization. The earlier Custom JSX login wait failed once and current exact-route wait remains.
- **Correction:** add shared renderer contract harness plus family E2Es. Replace Custom JSX’s exact post-submit URL race with a shared login helper that waits for authenticated session/stable destination, then run it three times in CI.
### WID-013 — P2 confirmed: open review-thread state no longer matches code
| Thread | Classification |
| ------------------------------------------- | ------------------------------------------------------------------- |
| iframe complete URL | already fixed/false positive at current line: only host is rendered |
| system resources network gate | already fixed, outdated |
| RSS total error | already fixed: no-data error throws; stale data retained |
| bookmarks memoization | valid P3/P2-low; benchmark first |
| network-controller total error | already fixed, outdated |
| network-controller breakpoint consolidation | valid P2 maintainability |
| Paperless total error | already fixed, outdated |
Resolve the five stale threads with evidence; address or explicitly disposition the two live suggestions. Re-run review because CodeRabbit was paused during active development.
## Systemic remediation roadmap
1. **Security/authorization:** WID-001 and WID-005; add negative-role/protocol tests before merge.
2. **Interaction accessibility:** replace manual focus with portaled Mantine modal semantics (WID-002/003), then test nested widget content.
3. **Truthful data state:** build the shared query-state contract and migrate all 39 ambiguous sites; keep valid stale data and partial results.
4. **Typed interfaces:** replace `widgetStateRef` slot mutation and `any` context dispatch with committed typed registrations.
5. **Widget families:** media/downloads, infrastructure, networking/security, smart-home, analytics/calendar/feed, then Beszel; apply URL, state, locale, responsive and a11y contracts in that order.
6. **Design-system cleanup:** semantic layers/colors and restrained Mantine consolidation. Do not add Schedule or upgrade to 9.5 without a separate dependency/bundle decision.
7. **Docs/PR:** correct renderer count, behavior and exact validation evidence.
8. **Final proof:** frozen install on supported Node/pnpm; format, lint, full typecheck, affected and full unit tests, production and docs builds; Custom JSX three times; complete production-container E2E; desktop/tablet/mobile keyboard/touch/light/dark/reduced-motion/zoom/a11y-tree QA. Push nothing until all refer to one unchanged SHA; close every review thread and obtain required approval.
## Positive findings
- Timetable is the strongest security work in the PR: saved resource authorization, private-range rejection, DNS rebinding resistance through pinned lookup, redirect rejection, bounds and schemas are coherently tested.
- Bounded response helpers, Beszel bounded queue/cancellation, and partial-integration settlement materially reduce failure blast radius.
- RSS preserves stale data and distinguishes partial feed failure; Network Controller and Paperless now correctly escalate total failures.
- Item-scoped query matchers and tests address sibling-widget refresh isolation; global polling defaults are restricted to the widget namespace.
- Touch long-press has movement tolerance; focus scale guards prevent Infinity/NaN; reduced-motion and remount cleanup are present.
- Existing Homarr theme, Mantine components and shared UI vocabulary are generally preserved. Most proposed Mantine work is consolidation, not redesign.
## Validation evidence and uncertainty
- `MANTINE.md` was fetched from `https://mantine.dev/llms.txt` and passed byte-for-byte `cmp`; `llms-full.txt` stayed in `/tmp`.
- Context7 current Mantine docs were queried for overlay/focus, responsive/data-state/accessibility, charts and Schedule. Static 9.4.1 archives verified exact exports; Cascader, BulletChart and SunburstChart are 9.5-only.
- Local frozen install could not start because host Node/pnpm are below repository engine requirements. No dependencies, source files or machine toolchain were changed.
- Docker API access is denied, so production-container E2E could not be rerun locally. Hosted exact-head Fast gate and Container/E2E are green.
- `agent-browser` is not installed. Temporary `npx` execution was rejected by the environment’s security reviewer, so live exploratory QA/a11y-tree inspection was not performed.
- External integrations were not available; malformed/partial/permission combinations are static-analysis conclusions unless a focused test already exists.
- This review intentionally changes no production code and makes no commit, push, thread resolution or PR edit.
Finalization must re-query the PR head/checks/threads. If the head differs from the reviewed SHA, this report is a snapshot and the delta requires review before merge.
**Final refresh (2026-08-03):** head remained `96e92485a5fd45f3648cfac0453e614779c414ba`; all reported hosted checks remained successful; merge state remained `BLOCKED` with no approval decision; unresolved threads remained 7 of 34. The branch did not change during the audit.
@@ -6,16 +6,36 @@ tags:
Homarr has the following keyboard shortcuts:
| Shortcut | Description |
| ----------- | -------------------- |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Shortcut | Description |
| ----------------------------------------------- | ------------------------------------------ |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `Esc` | Closes an open advanced view or board menu |
## Board editing
When a tile is focused in edit mode:
| Shortcut | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `Enter` or `Space` | Starts or stops keyboard editing |
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
| `Esc` | Stops keyboard editing without undoing its changes |
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
:::tip
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal
dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open.
:::
:::note
@@ -137,7 +137,8 @@ It is important that you first migrate correctly using [the list of breaking cha
## Creating your first board
In Homarr, you organize your apps and widgets on boards.
Boards can be organized using the interactive drag and drop.
Boards use fixed-size tiles so the same widget keeps the same proportions on every screen.
Drag, resize, and section controls are available in edit mode.
Let's create your first board by navigating here: `Manage` > `Boards` > `New board`:
![](./img/manage-create-board-button.png)
@@ -188,7 +189,12 @@ When entering the edit mode, you'll get some additional buttons to work with:
<img src={manageBoardHeaderEditMode} alt={"Screenshot of buttons in the edit mode"} width={200} />
- **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this btuton to save your changes and exit edit mode.
- **Exit edit mode**: Click this button to save your changes and exit edit mode.
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click the option "New Item":
@@ -213,21 +219,29 @@ In the modal you can edit the app that you want to display:
![](./img/manage-board-choose-app.png)
As soon as you save your changes, the tile on the board will refresh and display your app.
Using the small bidirectional arrow at the bottom right you can resize the element.
Start pressing with your mouse button down, move your mouse to resize and release your finger as soon your you're happy with the size.
Use the resize handle at the bottom right to resize the tile.
Press and hold the handle, move the pointer, and release it when the tile has the size you want.
![](./img/resize-item.gif)
To move your item to a different position on the board, simply hold the mouse button anywhere on the item and move with the mouse to the desired position.
As soon as you release your finger, the item will snap in place in the grid.
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
![](./img/move-item.gif)
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
Homarr scales the complete board uniformly, like zooming a page.
The complete canvas always fits the available width without horizontal board scrolling.
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget independently.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
:::note
You cannot move items freely on the vertical axis.
We always align all items to the top of the page to simply organization.
To move items to the bottom, you must add more items or resize items.
Viewing a board initially uses the lightweight, non-interactive layout.
For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive.
If you enter edit mode before that finishes, the existing board stays visible until the editor is ready.
:::
@@ -238,8 +252,10 @@ This helps you to explore the capabilities of Homarr before you build your board
:::
Users on touch or mobile devices may not be able to drag and drop or move items.
They can use the "Move / resize item" option in the edit menu of items instead:
Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize.
Press Escape to stop.
For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu.
The dialog can also move the tile to another section or fixed sidebar:
![](./img/manage-board-move-resize-item-menu.png)
@@ -311,43 +327,36 @@ As with every change, don't forget to save your changes using the exit edit mode
![](./img/exit-edit-mode.png)
## Grouping your items
## Organizing items with Containers
As the last step, let's organize your freshly created items using two different options.
You can choose either or both of them on your boards.
Containers are resizable areas for apps, widgets, and nested Containers.
The same Container works on the main canvas, inside a fixed rail, or inside another Container.
Each Container can configure:
### Using dynamic sections
- Its label and whether that label is visible.
- Allow or disable collapsing.
- Show or hide **Open all in new tabs**.
- Change its border color and custom CSS classes.
Dynamic sections are a simple, resizable visual group to group items.
Open the add-content menu and choose **New Container**.
In edit mode, move and resize it like any other item, then drag items into or out of it.
Nested content stays within the Container's bounds.
While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release.
![](./img/create-dynamic-section.png)
### Collapsing and opening apps
After adding a dynamic section, you can simply drag and drop items into the section:
Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size.
The full-width header becomes the compact expansion bar, so the half-row state has no empty pill-shaped spacer.
Expanding it restores its bounds and makes room for its contents.
If **Open all in new tabs** is enabled, the Container menu can open every app link it contains.
![](./img/dynamic-section.gif)
### Fixed rails
### Using categories
Categories are more advanced and complicated than dynamic sections.
To create a category, open the add item modal and choose "new category":
![](./img/add-category.png)
Every category must have a name. After you click "Add category", you will get a new item on your board:
![](./img/category.gif)
You can drag and drop items in to this category in the edit mode.
Categories will have a big space before and after them in the edit mode to
enable you to drag and drop items outside the category.
You can also collapse or open categories in edit mode to facilitate your editing process.
:::note
A current limitation of categories is, that they will always have a width of 100%.
You cannot reduce their horizontal width.
:::
Open the board's **Layout** settings to enable the left or right rail for each responsive layout.
Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width.
The rail reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions.
Rail content stays visible while the page scrolls and remains editable.
Each responsive layout keeps its own rail widths, item positions, and item sizes.
## Guided tours
+10 -10
View File
@@ -1,13 +1,13 @@
# Glossary
In the glossary, you'll learn the different components and their names of Homarr.
This glossary explains Homarr's main components and the terms used for them.
| Term | Definition |
| --------------- | -------------------------------------------------------------------------------------------------------------- |
| Apps | An piece of software that you or a third party hosts (eg. Google) |
| Integration | A connection to a service outside of Homarr |
| Dynamic section | Resizeable area where items on your board can be placed and grouped |
| Widget | A tile that uses your integrations to display data or enables you to interact with with them |
| Edit mode | Temporary state that you can enter where you can drag and drop, edit or customize your board |
| Board | Customizable page which can be edited using the edit mode. This is where apps, sections and widgets are placed |
| Tasks | Workloads that are performed in the background - even if there is no user present |
| Term | Definition |
| ----------- | --------------------------------------------------------------------------------------- |
| Apps | A piece of software that you or a third party hosts (for example, Google) |
| Integration | A connection to a service outside of Homarr |
| Container | A resizable area that can hold apps, widgets, and nested Containers |
| Widget | A tile that uses your integrations to display data or enables you to interact with them |
| Edit mode | A temporary state where you can drag, resize, edit, or customize your board |
| Board | A customizable page where apps, widgets, and Containers are arranged |
| Tasks | Workloads that are performed in the background, even if no user is present |
+14
View File
@@ -84,6 +84,20 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
an optional `error` on a failed integration; media-request responses expose a `failedIntegrations` array, and the latest
requests endpoint returns its items in `requests`. When every selected integration fails, the query returns an error instead
of a partial response.
### Saving dashboard layouts
The dashboard editor saves item positions and sections as one board update.
Canvas height follows its content automatically and does not require a separate API setting.
Responsive layouts store optional left and right gutter widths from zero to three columns.
Gutter columns are reserved from the layout's total column count, and changing them reflows affected items.
## Get Custom Widget authoring resources
Custom JSX v2 provides four HTTP discovery endpoints:
+96 -10
View File
@@ -36,8 +36,10 @@ Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
The column count defines how many fixed-width cells are available in each row.
One cell contains a `200 × 200` logical-pixel tile.
Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling.
The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge.
### Public
@@ -88,26 +90,78 @@ Homarr will fall back to the Homarr logo if nothing is set.
### Layout
In this section you can configure the different responsive layouts. Depending on your screens width the layout will change.
In this section you can configure the different responsive layouts.
Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly.
![](./img/layout-settings.png)
On each element the position and size of items can be changed seperately.
When adding a new layout the position and sizes will automatically be generated depending on the column count.
When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position.
Every `1 × 1` tile has a `200 × 200` logical-pixel content area.
The `24` logical-pixel gap between tiles is separate from that size.
A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them.
Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller.
When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size.
On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling.
The position and size of every item are independent in each responsive layout.
Moving or resizing an item in one layout does not change any other layout.
When adding a new layout, its initial positions and sizes are generated from the column count.
New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns.
Both are placed in the first available position.
The canvas is at least one window tall, even when it is empty.
Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward.
Nested Containers keep their own resizable bounds.
#### Name
The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board.
The name of the layout can be chosen freely. It is only used for management and will not be visible on the board.
#### Column count
Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items.
Total number of columns for this layout.
When changed, Homarr moves items into the next available positions without overlap.
#### Fixed rails
Each responsive layout can enable a fixed left rail, a fixed right rail, or both.
Set a rail width to zero to disable it for that layout.
Choose a width from one to three columns with the three-step slider.
The preview shows how much space remains for the main canvas.
Rail columns are reserved from the layout's total column count.
For example, a 12-column layout with a two-column left rail leaves ten columns for the canvas.
Enabling or widening a rail reflows canvas items into the remaining columns.
Disabling a rail moves its contents back into the next available canvas positions.
Rails fill the available window height and remain fixed while the page scrolls. They do not grow or scroll independently.
Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary.
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minimum of the next layout is reached the layout will change.
Once the minimum of the next layout is reached, the layout will change.
#### View and edit modes
View mode renders a static board without executing the drag-and-drop editor.
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; its settings button remains visible in the compact header above the tile.
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
Press Enter, Space, or Escape to stop keyboard editing.
Keyboard moves and resizes apply immediately; Escape stops keyboard editing without undoing those changes.
During a pointer or touch interaction, Escape cancels the move or resize and restores the layout from before that interaction.
The **Move / resize item** action in the edit menu remains available for precise numeric input.
#### Preview
@@ -115,6 +169,24 @@ Once the minimum of the next layout is reached the layout will change.
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
### Containers and rails
Containers can hold apps, widgets, and nested Containers.
The same Container model works on the main canvas, inside a fixed rail, or inside another Container.
Move and resize Containers directly in edit mode; nested content stays within the Container's local bounds.
Resize outlines follow the pointer continuously while a dashed preview marks the grid size that will be applied on release.
Each responsive layout stores its own Container position and size.
Each Container can configure:
- Its label and whether that label is visible.
- Whether users can collapse and expand it like an accordion.
- Whether its menu shows **Open all in new tabs** for the app links it contains.
- Its border color and custom CSS classes.
Collapsing a Container reduces its full-width header to a half-row expansion bar and compacts the surrounding layout without losing its expanded size.
Expanding it smoothly restores those bounds and moves following items out of the way without reloading the widgets inside it.
### Background
#### Background image URL
@@ -159,7 +231,7 @@ Change the color of your bookmark / app icons.
#### Item radius
Change the radius of your items / categories.
Change the radius of your items and Containers.
<img src={itemRadiusImage} height={100} />
@@ -176,6 +248,20 @@ The styling will only be applied on the respective board page.
This will disable the status check for apps.
#### Work directly from the board
Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run
supported actions. Available controls follow your board and integration permissions.
- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board
section and press `Shift` + `F10` for the keyboard equivalent.
Widget and empty-space context menus follow the **Enable right click on widgets** preference in your user settings.
### Access control
With the access control, you can permit a single user or a group to your board.
+7 -1
View File
@@ -4,6 +4,8 @@ description: "Display and edit a selected note from Anchor"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -15,6 +17,8 @@ import { anchorIntegration } from "@site/docs/integrations/anchor";
The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr.
Advanced view always shows the note title and update time alongside the complete formatted document, permission, creation date, and pinned or archived state. Compact title and update-time switches do not hide that information in advanced view.
### Screenshots
import anchorNoteWidgetScreenshot from "./img/widget.png";
@@ -33,10 +37,12 @@ import anchorNoteWidgetScreenshot from "./img/widget.png";
### Interactions
- Click **Edit** to start editing the selected note.
- Hover the widget, focus within it, or use a touch device to reveal **Edit**, then select it to start editing the note.
- Click **Save** to persist changes to Anchor.
- Click **Cancel** to discard unsaved changes.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+5
View File
@@ -25,6 +25,11 @@ import piHoleApp from "./img/pi-hole-ping-icon.png";
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
</div>
### Interactions
App tiles stay compact and open their configured destination directly. Right-click a tile to inspect its status or change its settings when the board and your preferences allow it.
### Adding the widget
<AddingWidget />
@@ -0,0 +1,38 @@
---
title: "ArchiveTeam Warrior"
description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs."
hide_title: true
---
import { archiveTeamWarriorIntegration } from "@site/docs/integrations/archiveteam-warrior";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { archiveTeamWarriorWidget } from ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and optional broadcast message visible. Advanced view adds the complete active-job list, job start times, project and bandwidth-session identifiers, session transfer totals, exact update time, and the full broadcast message whenever one is available. A failed refresh keeps the last snapshot visible with a stale-data warning.
### Supported integration
<WidgetIntegrations
items={[
{
integration: archiveTeamWarriorIntegration,
note: "Shows status and active archival work from one Warrior instance",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={archiveTeamWarriorWidget.configuration} />
@@ -0,0 +1,19 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconArchive } from "@tabler/icons-react";
export const archiveTeamWarriorWidget: WidgetDefinition = {
icon: IconArchive,
name: "ArchiveTeam Warrior",
description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs.",
path: "../../widgets/archive-team-warrior",
configuration: {
items: [
{
name: "Show broadcast message",
description: "Show the message broadcast by the selected Warrior project when space allows.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
@@ -4,6 +4,8 @@ description: "Displays media and listening statistics from Navidrome or Audioboo
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -36,6 +38,8 @@ Only options relevant to your linked integration are shown in the configuration
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+6
View File
@@ -4,6 +4,8 @@ description: "Displays missing subtitle counts and health indicators from your B
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -34,6 +36,10 @@ import widgetOverview from "./img/widget-overview.png";
]}
/>
Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count.
<WidgetInteractionGuide advancedView />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "View Beszel alert configurations and history with triggered/ok sta
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -32,6 +34,8 @@ This widget displays your Beszel alert configurations separated by status (trigg
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -34,6 +36,8 @@ Select a system card to open the complete statistics view, including system, fil
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Time-series charts for CPU, memory, disk, network, and Docker cont
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -15,7 +17,7 @@ import beszelSystemStats from "./img/beszel-system-stats.png";
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. Hover over or focus the widget controls to switch between the realtime Live view and historical periods ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -32,6 +34,8 @@ This widget displays time-series charts for a selected Beszel system, including
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Table view of all Beszel-monitored systems with sortable columns a
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -13,9 +15,9 @@ import { beszelIntegration } from "@site/docs/integrations/beszel";
<WidgetHeader widget={beszelSystemTableWidget} categories={["System Monitoring"]} />
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. System and load-average status dots are displayed inline with their values. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
Select a system row to open the complete statistics view.
### Interactions
@@ -35,6 +37,8 @@ Select a system row to open the complete statistics view, including system, file
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
+8 -2
View File
@@ -4,6 +4,8 @@ description: "Displays multiple app links"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -13,18 +15,20 @@ import { bookmarksWidget } from ".";
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from "./img/vertical-list-simple.png";
import verticalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<img src={gridIconsOnly} alt="bookmarks grid icons-only" width={128} height={128} />
<img src={vericalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
<img src={verticalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
</div>
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
@@ -32,6 +36,8 @@ import vericalListSimple from "./img/vertical-list-simple.png";
</div>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+6 -2
View File
@@ -4,6 +4,8 @@ description: "Display events from your integrations in a calendar view within a
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -70,8 +72,10 @@ import withoutIntegrations from "./img/without-integrations.png";
### Interactions
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
<WidgetInteractionGuide />
### Adding the widget
+4
View File
@@ -4,6 +4,8 @@ description: "Displays the current date and time."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -25,6 +27,8 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
+8
View File
@@ -4,6 +4,8 @@ description: "Overview of your Coolify instance with servers, applications, and
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -25,6 +27,12 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
]}
/>
Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning.
IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling.
<WidgetInteractionGuide advancedView />
### Adding the widget
<AddingWidget />
+8 -7
View File
@@ -1,24 +1,26 @@
---
title: "Custom API"
description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions."
description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { customApiWidget } from ".";
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets).
A definition contains API sources, named requests, a widget options schema, and one safe JSX template.
Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately.
Homarr does not send these credentials to the browser. Homarr does not include them in an export.
### Add the widget
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -29,9 +31,8 @@ Select a refresh interval. The board item stores the definition ID and the valid
Credentials remain on the server.
:::note
Enable the definition before you use it. The definition must also be available to the board.
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
Action buttons can only be executed by administrators.
:::
### Permissions and actions
@@ -1,9 +1,11 @@
---
title: "DNS Hole Controls"
description: "Control PiHole or AdGuard from your dashboard."
description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -18,7 +20,9 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
This widget allows you to enable or disable your DNS Hole.
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height.
### Screenshots
@@ -46,9 +50,12 @@ import smallSize from "./img/small.png";
### Interactions
- Enable / Disable one dns-hole by clicking on the badge.
- Disable one dns-hole in x seconds by clicking on the clock icon.
- Do all of the above for all dns-holes by using the top button row.
- Enable or disable one DNS service by clicking its status badge.
- Temporarily disable one service with the clock action.
- Apply an action to all available services from the top action row. Homarr keeps successful results visible and reports how many sources failed.
- Interaction controls only appear when the current user has permission for that integration.
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Displays the summary of your DNS Hole."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -15,7 +17,9 @@ import { dnsHoleSummaryWidget } from ".";
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more.
When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration.
This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source.
### Screenshots
@@ -50,6 +54,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
+10 -6
View File
@@ -4,6 +4,8 @@ description: "Allows you to view and manage your Downloads from both Torrent and
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { aria2Integration } from "@site/docs/integrations/aria2";
import { delugeIntegration } from "@site/docs/integrations/deluge";
import { nzbgetIntegration } from "@site/docs/integrations/nzbget";
@@ -41,12 +43,12 @@ import contextMenuImg from "./img/context-menu.png";
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Right-click context menu** — copy download names, or pause, resume, and delete when your account has Interact access and the client supports those actions
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Global controls** — pause/resume downloads from supported clients when your account has Interact access; read-only clients such as slskd do not expose these controls
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
@@ -87,13 +89,15 @@ import contextMenuImg from "./img/context-menu.png";
### Interactions
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Right-click** any download to copy its name; pause/resume and delete require Interact access and client support
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Pause / Resume** downloads from supported clients using the footer controls when you have Interact access; slskd remains read-only
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
- **Sort** the current view by clicking column headers to switch direction
<WidgetInteractionGuide />
### Adding the widget
+1 -1
View File
@@ -67,7 +67,7 @@ export const downloadsWidget: WidgetDefinition = {
{
name: "Hide completed torrent under this threshold (in kiB/s)",
description:
"This will hide completed torrent entries that have a download speed below the specified threshold.",
"This will hide completed torrent entries that have an upload speed below the specified threshold.",
values: "Any number above 0, 0 to disable",
defaultValue: "0",
},
@@ -4,6 +4,8 @@ description: "Displays a summary of firewalls."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
@@ -28,6 +30,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays information showing the health and status of your system(
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -21,6 +23,8 @@ import { glancesIntegration } from "@site/docs/integrations/glances";
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
Advanced view is a complete health workspace: it shows every available CPU, temperature, memory, GPU, filesystem, update, reboot, and system-information section even when compact visibility switches hide some of them on the board. Proxmox clusters likewise show uptime, CPU, memory, and every node, VM, container, and storage section. Storage-volume filters, units, and permissions remain in effect.
### Screenshots
import systemView from "./img/system-view.png";
@@ -62,6 +66,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+10 -7
View File
@@ -4,6 +4,8 @@ description: "Embed any content from the internet. Some websites may restrict ac
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -26,6 +28,8 @@ import youtubeStream from "./img/nasa-iss-stream.png";
<img src={youtubeStream} alt="iframe youtube-iss-stream" width={128 * 3} />
</div>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -35,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png";
Although you can integrate many websites, some may not work.
This is because the administrators of those sites disallow iframes
for security reasons, as users could easily be tricked
by embeding the original site on a malicious page.
by embedding the original site on a malicious page.
Some proxies disallow iframes by default for this reason.
You can use https://iframetest.com to check, if the site is allowing iframes.
You can use https://iframetest.com to check whether the site allows iframes.
If you manage all of your own applications, you can usually change that by configuring
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete
`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers.
For example, to allow your site to be embedded in iframes from specific domains:
@@ -49,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains:
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
@@ -82,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an
![transparent iframe](./img/transparent-iframe.png)
### Third party content
### Third-party content
:::warning
The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository.
The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository.
We cannot guarantee the functionality nor security of these tools.
:::
@@ -4,6 +4,8 @@ description: "Shows a slideshow of pictures from an Immich album or your full li
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { immichIntegration } from "../../integrations/immich";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -29,6 +31,10 @@ import screen from "./img/screen.png";
]}
/>
<WidgetInteractionGuide />
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip. If an Immich refresh fails, the last loaded album stays visible with a stale-data warning.
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Information about your Immich instance"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { immichIntegration } from "../../integrations/immich";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -29,6 +31,10 @@ import screen from "./img/screen.png";
]}
/>
In advanced mode, the widget also ranks the largest albums by asset count. To keep the dashboard response lightweight, it shows up to 50 albums.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "View the status of your indexers."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
@@ -37,6 +39,8 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr";
- Test all indexers by clicking on the test button.
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Track missing media and active downloads from Radarr and Sonarr"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -16,6 +18,8 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr";
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile.
### Screenshots
import widgetMissing from "./img/missing.png";
@@ -58,6 +62,8 @@ The widget adapts to its container size, so it looks good at any grid size:
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -1,9 +1,11 @@
---
title: "Media releases"
description: "Display newly added medias or upcoming releases from different integrations"
description: "Display newly added media or upcoming releases from different integrations"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -17,6 +19,10 @@ import { plexIntegration } from "@site/docs/integrations/plex";
Provides a list of newly added media or upcoming releases from your media server.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options.
### Screenshots
import backdrop from "./img/backdrop.png";
@@ -43,6 +49,8 @@ import poster from "./img/poster.png";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -44,6 +46,9 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Statistics about your media requests"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -17,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
### Supported Integrations
@@ -41,6 +43,9 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Show the current streams on your media servers"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -19,8 +21,12 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget enables you to watch and view current active sessions over all your media servers.
It also displays useful information, that you can use when providing support to users or debugging a problem.
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
### Screenshots
@@ -48,6 +54,7 @@ import mediaTable from "./img/table.png";
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Statistics, current queue and worker status of your media transcod
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -18,6 +20,7 @@ import { tdarrIntegration } from "@site/docs/integrations/tdarr";
The media transcoding widget provides you with an overview of your media transcoding status.
It shows you the current queue, worker status, and statistics about your transcoding jobs.
Advanced view presents all three panels together. If refreshes fail after data has loaded, Homarr keeps the last useful result visible and marks it as stale.
### Screenshots
@@ -41,6 +44,8 @@ import statisticsCard from "./img/statistics.png";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays the status of a Minecraft server."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -24,9 +26,11 @@ import bedrockItem from "./img/bedrock.png";
<div className="flex gap-1">
<img src={javaItem} alt="minecraft-server-status java-edition" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-editon" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-edition" width={128 * 1.5} height={128 * 1.5} />
</div>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -38,4 +42,4 @@ import bedrockItem from "./img/bedrock.png";
### Data source
We use the Minecraft Server Status API to fetch the server status.
If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`.
@@ -4,6 +4,8 @@ description: "Display connected devices on a network"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -18,6 +20,8 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
import wifiCard from "./img/wifi.png";
@@ -36,6 +40,8 @@ import wiredCard from "./img/wired.png";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays the summary of a Network Controller"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -16,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
Shows information whether your WAN is connected, Wi-Fi is active and several other information.
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
### Screenshots
@@ -34,6 +36,10 @@ import summaryCard from "./img/summary.png";
]}
/>
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+5 -1
View File
@@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -24,6 +26,8 @@ import editOn from "./img/edit.png";
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -34,7 +38,7 @@ import editOn from "./img/edit.png";
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
When your permissions allow it, hover the widget, focus within it, or use a touch device to reveal the edit button in the top right. You can also double-click the document to edit it directly.
![](./img/notebook-enter-editing.webp)
@@ -4,6 +4,8 @@ description: "Display notification history from an integration"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -32,6 +34,10 @@ import notificationsScreenshot from "./img/notifications.png";
items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
/>
Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior.
<WidgetInteractionGuide advancedView />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays document management statistics including inbox ratio, doc
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -26,6 +28,8 @@ This widget displays statistics from your Paperless-ngx instance, including an i
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays host patch statistics from PatchMon including update and
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -70,6 +72,10 @@ The widget adapts its layout based on the available space:
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Advanced view
Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect.
### Supported Integrations
<WidgetIntegrations
@@ -81,6 +87,8 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -33,6 +35,10 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Monitor and display one or more generic RSS, ATOM or JSON feeds."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -19,6 +21,10 @@ import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration.
<WidgetInteractionGuide advancedView />
### Adding the widget
<AddingWidget />
@@ -27,6 +33,8 @@ import screenshot from "./img/screenshot.png";
<WidgetConfig configuration={rssFeedWidget.configuration} />
If one configured feed fails, the widget keeps entries from healthy feeds visible and shows a warning. If a refresh fails after entries have loaded, the previous entries stay visible with a stale-data warning.
### Examples
Here's a small collection of examples, that you can use for testing or actual use (in no particular order):
@@ -4,6 +4,8 @@ description: "Display the state of an entity and toggle it optionally"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -20,6 +22,8 @@ The entity state enables you to display the current state of an entity.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded.
### Screenshots
import sun from "./img/sun.png";
@@ -43,6 +47,9 @@ import temperature from "./img/temperature.png";
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers.
<WidgetInteractionGuide />
### Adding the widget
@@ -4,6 +4,8 @@ description: "Trigger an automation with one click"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -18,6 +20,8 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
The execute automation widget allows you to trigger an automation with one click.
Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history.
### Screenshots
import empty from "./img/empty.png";
@@ -47,6 +51,8 @@ import withDisplayname from "./img/with-displayname.png";
- Trigger the automation by clicking on the widget.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays speed test results from your Speedtest Tracker instance."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -16,6 +18,10 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Screenshots
import screenshot from "./img/screenshot.png";
@@ -32,6 +38,8 @@ import screenshot from "./img/screenshot.png";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays the current stock price of a company."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -12,6 +14,9 @@ import { stockPriceWidget } from ".";
<WidgetHeader widget={stockPriceWidget} categories={["Finance", "Market"]} />
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them.
In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval.
### Screenshots
@@ -31,6 +36,8 @@ import nvidia from "./img/nvidia.png";
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Disk usage of your system"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -42,6 +44,10 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
Advanced view keeps your configured volume filters while showing each integration name, usage percentage, used and total space, free-space percentage, SMART status, available temperature, and the usage background bar regardless of compact display options.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays departure times for a station."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -23,6 +25,8 @@ import streetDepartures from "./img/street-departures.png";
<img src={streetDepartures} alt="timetable-widget-bus-and-tram-departures" width={128 * 2} />
</div>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -30,3 +34,7 @@ import streetDepartures from "./img/street-departures.png";
### Configuration
<WidgetConfig configuration={timetableWidget.configuration} />
For security, all custom base URLs must resolve to a public IP address whenever Homarr uses them. Private and local hosts are rejected.
If a timetable refresh fails after departures have loaded, the widget keeps the previous departures visible and marks them as stale. The advanced view also shows their last update time.
+1 -1
View File
@@ -11,7 +11,7 @@ export const timetableWidget: WidgetDefinition = {
{
name: "Search.ch URL",
description: "Base URL for the Search.ch timetable API.",
values: "Any valid URL.",
values: "An HTTP(S) URL that resolves to a public IP address.",
defaultValue: "https://search.ch",
},
{
@@ -4,6 +4,8 @@ description: "Monitor media server streams, user activity, and policy violations
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -15,6 +17,8 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Sections
| Section | Description |
@@ -34,6 +38,8 @@ import streamsAndStats from "./img/streams-and-stats.png";
<img src={streamsAndStats} alt="tracearr-streams-and-stats" width={128 * 4} />
</div>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+8
View File
@@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -15,6 +17,10 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
@@ -26,6 +32,8 @@ This widget displays a compact overview of your Traefik instance, including tota
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+4
View File
@@ -4,6 +4,8 @@ description: "Display visitor stats from your Umami analytics instance"
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -25,6 +27,8 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+8
View File
@@ -4,6 +4,8 @@ description: "Monitor the status of your UPS devices through a NUT server."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -17,6 +19,10 @@ This widget displays the status of the UPS devices reported by a Network UPS Too
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
output voltage.
Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present.
When multiple UPS integrations are selected, devices from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Layouts
import screenshotFull from "./img/screenshot.png";
@@ -41,6 +47,8 @@ The widget adapts to the size of the tile, so it stays readable from a full dash
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -4,6 +4,8 @@ description: "Displays monitor uptime statistics, average uptime percentage, and
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -15,6 +17,10 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
When multiple Uptime Kuma integrations are selected, monitors from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
### Supported Integrations
<WidgetIntegrations
@@ -26,6 +32,8 @@ This widget displays uptime statistics from your Uptime Kuma status page, includ
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
+4
View File
@@ -4,6 +4,8 @@ description: "Embed a video stream or video from a camera or a website."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -25,6 +27,8 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp
![video big-buck-bunny](./img/bunny.png)
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
+1 -1
View File
@@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = {
icon: IconDeviceCctv,
name: "Video Stream",
description: "Embed a video stream or video from a camera or a website.",
path: "../../widgets/stock-price",
path: "../../widgets/video",
configuration: {
items: [
{
+4
View File
@@ -4,6 +4,8 @@ description: "Monitor the status of your VPN integrations."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
@@ -36,6 +38,8 @@ import singleIntegrationImage from "./img/single.png";
]}
/>
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget />
+7
View File
@@ -4,6 +4,8 @@ description: "Displays the current weather information of a set location."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -12,6 +14,9 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
The Weather Widget uses your location to display the current, highest and lowest temperature.
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
The advanced view always combines current conditions with the full seven-day forecast, including humidity, wind,
gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response.
### Screenshots
@@ -23,6 +28,8 @@ import currentWeather from "./img/current.png";
<img src={currentWeather} alt="weather current" width={128 * 3} />
</div>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
@@ -8,6 +8,7 @@ import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
@@ -26,6 +27,10 @@ This widget shows two stats from your WUD instance: the total number of monitore
]}
/>
<WidgetInteractionGuide />
In advanced view, the widget always shows its title, update ring, monitored-container summary, and the full list of available container updates. The configured horizontal or vertical layout still applies. Visibility options only affect the compact board view. A failed refresh keeps the last WUD snapshot visible with a stale-data warning.
### Adding the widget
<AddingWidget />
@@ -0,0 +1,62 @@
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
const advancedInteraction = {
icon: IconArrowsMaximize,
title: "Advanced view",
description:
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
} as const;
const standardInteractions = [
{
icon: IconMouse2,
title: "Widget actions",
description:
"Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.",
},
{
icon: IconPlus,
title: "Place it precisely",
description:
"With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.",
},
{
icon: IconLayoutGrid,
title: "Adaptive detail",
description:
"Resize a widget to tune its density. Compact views keep primary status visible, then reveal details and controls as space or intent allows.",
},
] as const;
export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => {
const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions;
return (
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
<span>Power-user controls</span>
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
Hide shortcuts
</span>
</summary>
<div
className={`grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 ${advancedView ? "xl:grid-cols-4" : "xl:grid-cols-3"} dark:border-[#333]`}
>
{interactions.map(({ icon: Icon, title, description }) => (
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
<div className="font-semibold">{title}</div>
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
</div>
))}
</div>
{advancedView && (
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed. On
touch devices, use the widget menu; compact-only widgets adapt in place instead.
</p>
)}
</details>
);
};
+4
View File
@@ -79,6 +79,10 @@ const nextConfig: NextConfig = {
{
source: "/(.*)", // Apply CSP to all routes
headers: [
{
key: "Accept-CH",
value: "Sec-CH-Viewport-Width",
},
{
key: "Content-Security-Policy",
// worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp
+4 -1
View File
@@ -14,8 +14,12 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@dnd-kit/abstract": "catalog:",
"@dnd-kit/collision": "catalog:",
"@dnd-kit/core": "catalog:",
"@dnd-kit/dom": "catalog:",
"@dnd-kit/modifiers": "catalog:",
"@dnd-kit/react": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
@@ -32,7 +36,6 @@
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "catalog:",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
@@ -0,0 +1,53 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
min-width: 0;
}
.gutter {
--board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1));
--board-gutter-viewport-height: calc(
(100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1)
);
position: sticky;
top: var(--board-gutter-sticky-top);
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
}
.gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,33 +1,124 @@
"use client";
import { Box, Stack } from "@mantine/core";
import { Box } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0);
if (!currentLayout) throw new Error("Expected the board to contain a layout");
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const mainSection = getRootSectionForLane(board, "main");
const leftSection = getRootSectionForLane(board, "left");
const rightSection = getRootSectionForLane(board, "right");
if (!mainSection) throw new Error("Expected the board to contain a main canvas section");
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalTrackSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
);
};
@@ -2,9 +2,6 @@ import type { Metadata } from "next";
import { cache } from "react";
import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
@@ -21,7 +18,7 @@ import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
@@ -76,7 +73,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
<ClientBoard />
</IntegrationProvider>
</HydrationBoundary>
);
@@ -1,24 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { SimpleGrid, Skeleton, Stack } from "@mantine/core";
const BoardLoadingShell = () => (
<Stack h="100%" p="md" aria-busy aria-label="Loading dashboard" role="status">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[2, 1, 2, 2, 1, 2].map((rows, index) => (
<Skeleton
key={index}
data-homarr-dev-benchmark-board-shell
height={rows * 96}
radius="var(--mantine-radius-default)"
/>
))}
</SimpleGrid>
</Stack>
);
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
loading: BoardLoadingShell,
});
@@ -1,8 +1,7 @@
"use client";
import { useCallback } from "react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconBoxAlignTop, IconChevronDown, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -12,38 +11,43 @@ import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardAddMenu = () => {
export default function BoardEditActions() {
return <AddMenu />;
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions();
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const t = useI18n();
const handleAddCategory = useCallback(
() =>
openCategoryEditModal(
{
category: { id: "new", name: "" },
onSuccess({ name }) {
addCategoryToEnd({ name });
},
submitLabel: t("section.category.create.submit"),
},
{ title: (translate) => translate("section.category.create.title") },
),
[addCategoryToEnd, openCategoryEditModal, t],
);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({});
};
return (
<Menu position="bottom-end">
@@ -56,29 +60,18 @@ export const BoardAddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => openItemSelectModal({ boardId: board.id })}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{t("item.action.create")}
</Menu.Item>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
openAppSelectModal({
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
withCreate: session?.user.permissions.includes("app-create") ?? false,
})
}
>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{t("app.action.add")}
</Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => openIntegrationSelectModal({})}>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
{t("section.category.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
{t("section.dynamic.action.create")}
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{t("section.container.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
@@ -1,11 +1,11 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconPlus, IconReplace, IconSettings } from "@tabler/icons-react";
import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -19,22 +19,32 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
import type * as EditActionsModule from "./_edit-actions";
const loadBoardAddMenu = () => import("./_board-add-menu");
const preloadBoardAddMenu = () => void loadBoardAddMenu().catch(() => undefined);
const BoardAddMenu = dynamic(() => loadBoardAddMenu().then(({ BoardAddMenu: AddMenu }) => AddMenu), {
loading: () => (
<HeaderButton loading>
<IconPlus stroke={1.5} />
</HeaderButton>
),
});
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
@@ -43,13 +53,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
return (
<>
{isEditMode && <BoardAddMenu />}
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}>
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
@@ -62,10 +72,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const commonT = useI18n();
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
@@ -94,27 +105,71 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
close();
}, [utils, board.name, close]);
const toggle = useCallback(() => {
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const toggle = useCallback(async () => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(latestBoardRef.current);
}
open();
}, [isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
startTransition(open);
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
return (
<TourTarget id="board-edit-mode">
<HeaderButton
onClick={toggle}
loading={isPending}
onClick={() => void toggle()}
onFocus={prewarmEditor}
onPointerEnter={prewarmEditor}
loading={isPending || isEnteringEditMode}
data-testid="board-edit-mode-toggle"
aria-label={isEditMode ? commonT("common.action.save") : commonT("common.action.edit")}
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-pressed={isEditMode}
onFocus={preloadBoardAddMenu}
onPointerEnter={preloadBoardAddMenu}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
@@ -123,6 +178,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
};
const SelectBoardsMenu = () => {
const t = useI18n();
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
@@ -133,7 +189,7 @@ const SelectBoardsMenu = () => {
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -0,0 +1,54 @@
import { createElement, act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardIntegrationProvider } from "./_integration-provider";
const mocks = vi.hoisted(() => ({
providedIntegrations: undefined as unknown,
}));
vi.mock("@homarr/auth/client", async () => {
const React = await import("react");
return {
IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => {
mocks.providedIntegrations = integrations;
return React.createElement(React.Fragment, null, children);
},
};
});
describe("board integration provider", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
mocks.providedIntegrations = undefined;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("provides the permission-filtered server result without a second client query", () => {
const initialIntegrations = [{ id: "initial" }];
act(() => {
root.render(
createElement(
BoardIntegrationProvider,
{ initialIntegrations: initialIntegrations as never },
createElement("span", null, "board"),
),
);
});
expect(mocks.providedIntegrations).toBe(initialIntegrations);
});
});
@@ -0,0 +1,14 @@
"use client";
import type { PropsWithChildren } from "react";
import type { RouterOutputs } from "@homarr/api";
import { IntegrationProvider } from "@homarr/auth/client";
interface Props {
initialIntegrations: RouterOutputs["integration"]["all"];
}
export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren<Props>) => {
return <IntegrationProvider integrations={initialIntegrations}>{children}</IntegrationProvider>;
};
@@ -0,0 +1,47 @@
.gutterSettings {
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
}
.gutterPreview {
display: grid;
gap: 6px;
width: 100%;
min-height: 112px;
padding: 8px;
overflow: hidden;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
transition: grid-template-columns 180ms ease-out;
}
.previewCanvas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
min-width: 0;
}
.previewCanvas > div,
.previewGutter {
background: var(--mantine-color-default);
border-radius: var(--mantine-radius-sm);
}
.previewCanvas > div:nth-child(1) {
grid-row: span 2;
}
.previewCanvas > div:nth-child(4) {
grid-column: span 2;
}
.previewGutter {
min-width: 12px;
}
@media (prefers-reduced-motion: reduce) {
.gutterPreview {
transition: none;
}
}
@@ -1,6 +1,20 @@
"use client";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import {
Box,
Button,
Fieldset,
Grid,
Group,
Input,
NumberInput,
Paper,
Slider,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
@@ -8,6 +22,7 @@ import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
interface Props {
form: UseFormReturnType<FormValues>;
@@ -27,7 +42,17 @@ export const LayoutSettingsContent = ({ form }: Props) => {
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
layouts: [
...(previous.layouts ?? []),
{
id: createId(),
name: "",
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
},
],
}));
}}
>
@@ -44,7 +69,21 @@ export const LayoutSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
<Slider
mt="xs"
min={1}
max={24}
step={1}
value={layout.columnCount}
thumbLabel={t("layout.field.columnCount.label")}
onChange={(columnCount) => {
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
}}
/>
</Input.Wrapper>
</Grid.Col>
@@ -55,6 +94,15 @@ export const LayoutSettingsContent = ({ form }: Props) => {
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
<Grid.Col span={12}>
<GutterSettings
left={layout.leftGutterColumnCount}
right={layout.rightGutterColumnCount}
columnCount={layout.columnCount}
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
@@ -78,3 +126,123 @@ export const LayoutSettingsContent = ({ form }: Props) => {
</SectionCard>
);
};
interface GutterSettingsProps {
left: number;
right: number;
columnCount: number;
onLeftChange: (value: number) => void;
onRightChange: (value: number) => void;
}
const gutterMarks = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
];
const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => {
const t = useI18n();
const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1));
return (
<Paper className={classes.gutterSettings} p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("layout.field.gutters.label")}</Text>
<Text size="sm" c="dimmed">
{t("layout.field.gutters.description")}
</Text>
</Box>
<GutterPreview left={left} right={right} columnCount={columnCount} />
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<GutterControl
side="left"
value={left}
max={Math.min(maxGutterWidth, columnCount - right - 1)}
onChange={onLeftChange}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<GutterControl
side="right"
value={right}
max={Math.min(maxGutterWidth, columnCount - left - 1)}
onChange={onRightChange}
/>
</Grid.Col>
</Grid>
</Stack>
</Paper>
);
};
const GutterControl = ({
side,
value,
max,
onChange,
}: {
side: "left" | "right";
value: number;
max: number;
onChange: (value: number) => void;
}) => {
const t = useI18n();
const enabled = value > 0;
return (
<Stack gap="sm">
<Switch
checked={enabled}
disabled={max < 1}
onChange={(event) => onChange(event.currentTarget.checked ? Math.min(2, max) : 0)}
label={t(`layout.field.gutters.${side}.label`)}
description={t(`layout.field.gutters.${side}.description`)}
/>
{enabled && (
<Input.Wrapper label={t("layout.field.gutters.width.label")}>
<Slider
mt="xs"
min={1}
max={max}
value={Math.min(value, max)}
marks={gutterMarks.filter((mark) => mark.value <= max)}
restrictToMarks
thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)}
onChange={(nextValue) => onChange(Math.min(nextValue, max))}
/>
</Input.Wrapper>
)}
</Stack>
);
};
const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => {
const t = useI18n();
const safeColumnCount = Math.max(1, columnCount);
const main = Math.max(1, safeColumnCount - left - right);
const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null]
.filter((track) => track !== null)
.join(" ");
return (
<Box
className={classes.gutterPreview}
aria-label={t("layout.field.gutters.preview.label")}
style={{ gridTemplateColumns: tracks }}
>
{left > 0 && <Box className={classes.previewGutter} data-side="left" />}
<Box className={classes.previewCanvas}>
<Box />
<Box />
<Box />
<Box />
</Box>
{right > 0 && <Box className={classes.previewGutter} data-side="right" />}
</Box>
);
};
@@ -1,4 +1,5 @@
import type { JSX, PropsWithChildren } from "react";
import { cookies, headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
@@ -7,6 +8,7 @@ import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ModalProvider } from "@homarr/modals";
@@ -48,6 +50,7 @@ export const createBoardLayout = <TParams extends Params>({
(error: unknown) => ({ status: "rejected", error }) as const,
);
const colorSchemePromise = getCurrentColorSchemeAsync();
const initialViewportWidthPromise = getInitialViewportWidthAsync();
const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
if (!withTour || !session || env.DEMO_MODE) return false;
@@ -59,11 +62,12 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
initialViewportWidthPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
@@ -84,9 +88,14 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
return (
<BoardProvider initialBoard={initialBoard}>
<BoardProvider
initialBoard={initialBoard}
initialLayoutId={initialLayoutId}
initialViewportWidth={initialViewportWidth}
>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
@@ -99,7 +108,7 @@ export const createBoardLayout = <TParams extends Params>({
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</ModalProvider>
@@ -112,3 +121,22 @@ export const createBoardLayout = <TParams extends Params>({
return Layout;
};
const getInitialViewportWidthAsync = async () => {
const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value;
const cookieWidth = Number(cookieValue);
if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) {
return cookieWidth;
}
const requestHeaders = await headers();
const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width"));
if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) {
return clientHintWidth;
}
const userAgent = requestHeaders.get("user-agent") ?? "";
if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024;
if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390;
return 1440;
};
@@ -7,10 +7,9 @@ export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type DynamicSectionLayout = DynamicSection["layouts"][number];
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
export type ContainerSection = Extract<Section, { kind: "container" }>;
export type ContainerSectionLayout = ContainerSection["layouts"][number];
export type ContainerSectionItem = Omit<ContainerSection, "layouts"> & ContainerSectionLayout & { type: "section" };
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
+2
View File
@@ -32,6 +32,7 @@ import { env } from "~/env";
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
import { ViewportHint } from "~/components/layout/viewport-hint";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import { DayJsLoader } from "./_client-providers/dayjs-loader";
import { JotaiProvider } from "./_client-providers/jotai";
@@ -166,6 +167,7 @@ export default async function Layout(props: {
</head>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<ViewportHint />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
@@ -47,7 +47,6 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
}
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
@@ -0,0 +1,212 @@
.backdrop {
pointer-events: none;
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.manualBackdrop {
pointer-events: auto;
}
.surface {
position: fixed;
z-index: 1;
pointer-events: auto;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-md);
overscroll-behavior: contain;
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
animation-fill-mode: backwards;
animation-play-state: paused;
transform-origin: top left;
}
.surfaceReady {
animation-play-state: running;
}
.manualSurface {
position: fixed;
max-width: none;
max-height: none;
overflow: visible;
background: transparent;
box-shadow: none;
overscroll-behavior: contain;
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
}
.manualSurface > div {
width: 100%;
height: 100%;
max-height: none !important;
overflow: visible;
}
.backdropClosing {
animation: focus-backdrop-out 180ms ease-in forwards;
}
.surfaceClosing {
animation: focus-surface-out 180ms ease-in forwards;
}
.sourcePlaceholder {
pointer-events: none;
}
.surfaceHost {
width: 100%;
height: 100%;
}
.surfacePortalTarget,
.manualSurfaceMount {
width: 100%;
height: 100%;
min-height: 0;
}
.manualSurfaceMount {
overflow: hidden;
border-radius: inherit;
}
.surfaceHostAdvanced {
position: fixed;
inset: 0;
z-index: var(--homarr-z-index-widget-focus);
width: auto;
height: auto;
pointer-events: none;
}
.closeButton {
position: absolute;
z-index: 2;
pointer-events: auto;
}
.holdIndicator {
position: fixed;
top: 0;
left: 0;
z-index: var(--mantine-z-index-modal);
display: grid;
width: 36px;
height: 36px;
color: var(--mantine-color-text);
pointer-events: none;
transform: translate3d(var(--advanced-focus-pointer-x), var(--advanced-focus-pointer-y), 0) translate(-50%, -50%);
place-items: center;
will-change: transform;
}
.holdIndicatorRing {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%));
transform: rotate(-90deg);
}
.holdIndicatorTrack,
.holdIndicatorProgress {
stroke-width: 3;
}
.holdIndicatorTrack {
fill: color-mix(in srgb, var(--mantine-color-body) 82%, transparent);
stroke: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
}
.holdIndicatorProgress {
fill: none;
stroke: var(--mantine-primary-color-filled);
stroke-linecap: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: advanced-focus-hold-progress 500ms linear forwards;
}
.holdIndicatorLabel {
position: relative;
font-size: 15px;
font-weight: 700;
line-height: 1;
text-shadow: 0 1px 2px rgb(0 0 0 / 25%);
}
@keyframes focus-backdrop-in {
from {
opacity: 0;
}
}
@keyframes focus-surface-in {
from {
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
scale(var(--focus-scale-x), var(--focus-scale-y));
opacity: 0.72;
}
}
@keyframes focus-backdrop-out {
to {
opacity: 0;
}
}
@keyframes focus-surface-out {
to {
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
scale(var(--focus-scale-x), var(--focus-scale-y));
opacity: 0;
}
}
@keyframes advanced-focus-hold-progress {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface,
.manualSurface {
animation: none;
}
.holdIndicatorProgress {
stroke-dashoffset: 0;
animation: none;
}
}
@media (forced-colors: active) {
.backdrop {
background: Canvas;
opacity: 0.4;
}
.holdIndicatorTrack {
fill: Canvas;
}
.holdIndicatorTrack {
stroke: GrayText;
}
.holdIndicatorProgress {
stroke: Highlight;
}
.holdIndicatorLabel {
color: CanvasText;
text-shadow: none;
}
}
@@ -0,0 +1,195 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { BoardAdvancedFocusProvider, useAdvancedFocus } from "./context";
const sourceRect = {
left: 20,
top: 30,
right: 220,
bottom: 150,
width: 200,
height: 120,
x: 20,
y: 30,
toJSON: () => undefined,
};
describe("BoardAdvancedFocusProvider preview interaction", () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let focus: ReturnType<typeof useAdvancedFocus>;
const Harness = () => {
focus = useAdvancedFocus();
return createElement(
"div",
null,
createElement("div", { "data-testid": "source" }),
createElement("div", { "data-advanced-focus-surface": true, "data-testid": "surface" }),
createElement(
"div",
{ "data-portal": true },
createElement("button", { role: "menuitem", "data-testid": "portalled-menu-item" }),
),
createElement("div", { "data-testid": "outside" }),
);
};
beforeEach(async () => {
vi.useFakeTimers();
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
Object.defineProperty(document, "elementsFromPoint", {
configurable: true,
value: vi.fn(() => []),
});
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () =>
root.render(
createElement(
MantineProvider,
null,
createElement(
EditModeProvider,
null,
createElement(BoardAdvancedFocusProvider, null, createElement(Harness)),
),
),
),
);
const source = host.querySelector<HTMLElement>("[data-testid='source']");
if (!source) throw new Error("Expected source fixture");
vi.spyOn(source, "getBoundingClientRect").mockReturnValue(sourceRect);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.useRealTimers();
vi.unstubAllGlobals();
});
const startPreview = async () => {
const source = host.querySelector<HTMLElement>("[data-testid='source']");
if (!source) throw new Error("Expected source fixture");
await act(async () => {
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 48, clientY: 52 }));
focus.hover("widget-1", source);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" }));
});
};
test("shows pointer-following hold feedback for the full 500 ms delay", async () => {
await startPreview();
const indicator = document.querySelector<HTMLElement>("[data-advanced-focus-hold-indicator]");
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-x")).toBe("48px");
expect(indicator?.style.getPropertyValue("--advanced-focus-pointer-y")).toBe("52px");
expect(focus.active).toBeNull();
await act(async () => vi.advanceTimersByTime(499));
expect(focus.active).toBeNull();
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).not.toBeNull();
await act(async () => vi.advanceTimersByTime(1));
expect(focus.active?.itemId).toBe("widget-1");
expect(focus.active?.activation).toBe("preview");
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
});
test("does not show hold feedback when Shift is pressed without a hovered source", async () => {
await act(async () => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Shift" })));
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
await act(async () => vi.advanceTimersByTime(500));
expect(focus.active).toBeNull();
});
test("cancels the hold feedback and activation when Shift is released early", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(250));
await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })));
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
await act(async () => vi.advanceTimersByTime(250));
expect(focus.active).toBeNull();
});
test("cancels the hold feedback and activation when the source is left early", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(250));
await act(async () => focus.leave("widget-1"));
expect(document.querySelector("[data-advanced-focus-hold-indicator]")).toBeNull();
await act(async () => vi.advanceTimersByTime(250));
expect(focus.active).toBeNull();
});
test("keeps the preview open across the source-to-portal gap and closes after leaving both", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(500));
await act(async () => focus.leave("widget-1"));
await act(async () => vi.advanceTimersByTime(40));
const surface = host.querySelector<HTMLElement>("[data-testid='surface']");
if (!surface) throw new Error("Expected surface fixture");
await act(async () =>
surface.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 300, clientY: 200 })),
);
await act(async () => vi.advanceTimersByTime(80));
expect(focus.active?.phase).toBe("visible");
const outside = host.querySelector<HTMLElement>("[data-testid='outside']");
if (!outside) throw new Error("Expected outside fixture");
await act(async () =>
outside.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX: 500, clientY: 500 })),
);
await act(async () => vi.advanceTimersByTime(79));
expect(focus.active?.phase).toBe("visible");
await act(async () => vi.advanceTimersByTime(1));
expect(focus.active?.phase).toBe("closing");
await act(async () => vi.advanceTimersByTime(180));
expect(focus.active).toBeNull();
});
test("keeps the preview open while a widget-owned portalled popup is hovered", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(500));
await act(async () => focus.leave("widget-1"));
await act(async () => vi.advanceTimersByTime(40));
const menuItem = host.querySelector<HTMLElement>("[data-testid='portalled-menu-item']");
if (!menuItem) throw new Error("Expected portalled menu fixture");
await act(async () =>
menuItem.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, clientX: 320, clientY: 210 })),
);
await act(async () => vi.advanceTimersByTime(80));
expect(focus.active?.phase).toBe("visible");
});
});
@@ -0,0 +1,369 @@
"use client";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { useEditMode } from "@homarr/boards/edit-mode";
import classes from "./advanced-focus.module.css";
import { isEscapeOwnedByNestedOverlay } from "./escape";
import type { FocusRect } from "./geometry";
interface ActiveFocus {
itemId: string;
source: HTMLElement;
restoreFocusTarget: HTMLElement;
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
restorePreviewFocus: boolean;
phase: "visible" | "closing";
}
interface OpenAdvancedFocusOptions {
activation?: ActiveFocus["activation"];
autofocusClose?: boolean;
restoreFocusTarget?: HTMLElement;
}
interface AdvancedFocusContextValue {
active: ActiveFocus | null;
viewportSize: { width: number; height: number };
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
hover: (itemId: string, source: HTMLElement) => void;
leave: (itemId: string) => void;
}
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
const HOVER_DELAY_MS = 500;
const POINTER_TRANSITION_GRACE_MS = 80;
const CLOSE_DURATION_MS = 180;
const isEditableTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
(target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable);
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const viewportSize = useViewportSize();
const [active, setActive] = useState<ActiveFocus | null>(null);
const [isHoldPending, setIsHoldPending] = useState(false);
const activeRef = useRef<ActiveFocus | null>(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdIndicatorRef = useRef<HTMLDivElement | null>(null);
const pointerPositionRef = useRef({ x: -100, y: -100 });
const pointerFrameRef = useRef<number | null>(null);
const cancelHoverTimer = useCallback(() => {
if (hoverTimerRef.current === null) return;
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
setIsHoldPending(false);
}, []);
const cancelPreviewLeaveTimer = useCallback(() => {
if (previewLeaveTimerRef.current === null) return;
clearTimeout(previewLeaveTimerRef.current);
previewLeaveTimerRef.current = null;
}, []);
const updateActive = useCallback((next: ActiveFocus | null) => {
activeRef.current = next;
setActive(next);
}, []);
const cancelCloseTimer = useCallback(() => {
if (closeTimerRef.current === null) return;
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}, []);
const open = useCallback(
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
if (isEditMode) return;
cancelHoverTimer();
cancelPreviewLeaveTimer();
cancelCloseTimer();
const rect = source.getBoundingClientRect();
updateActive({
itemId,
source,
restoreFocusTarget: options.restoreFocusTarget ?? source,
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
restorePreviewFocus: options.activation === "preview" && document.activeElement === source,
phase: "visible",
});
},
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, isEditMode, updateActive],
);
const close = useCallback(
(restoreFocus = true) => {
cancelHoverTimer();
cancelPreviewLeaveTimer();
const current = activeRef.current;
if (!current || current.phase === "closing") return;
updateActive({ ...current, phase: "closing" });
const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS;
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
updateActive(null);
if (restoreFocus && current.restoreFocusTarget.isConnected)
requestAnimationFrame(() => current.restoreFocusTarget.focus());
}, closeDuration);
},
[cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
);
const schedulePreviewClose = useCallback(
(itemId: string) => {
if (previewLeaveTimerRef.current !== null) return;
previewLeaveTimerRef.current = setTimeout(() => {
previewLeaveTimerRef.current = null;
const current = activeRef.current;
if (current?.activation !== "preview" || current.itemId !== itemId) return;
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
close(current.restorePreviewFocus);
}, POINTER_TRANSITION_GRACE_MS);
},
[close],
);
const startHoverTimer = useCallback(() => {
cancelHoverTimer();
const hovered = hoveredRef.current;
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
if (pointerPositionRef.current.x < 0 || pointerPositionRef.current.y < 0) {
const rect = hovered.source.getBoundingClientRect();
pointerPositionRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
setIsHoldPending(true);
hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null;
setIsHoldPending(false);
open(hovered.itemId, hovered.source, { activation: "preview" });
}, HOVER_DELAY_MS);
}, [cancelHoverTimer, open]);
const hover = useCallback(
(itemId: string, source: HTMLElement) => {
hoveredRef.current = { itemId, source };
cancelPreviewLeaveTimer();
const current = activeRef.current;
if (current?.activation === "preview" && current.itemId !== itemId) {
cancelCloseTimer();
updateActive(null);
if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true });
}
startHoverTimer();
},
[cancelCloseTimer, cancelPreviewLeaveTimer, startHoverTimer, updateActive],
);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
if (holdIndicatorRef.current && pointerFrameRef.current === null) {
pointerFrameRef.current = requestAnimationFrame(() => {
pointerFrameRef.current = null;
const indicator = holdIndicatorRef.current;
if (!indicator) return;
indicator.style.setProperty("--advanced-focus-pointer-x", `${pointerPositionRef.current.x}px`);
indicator.style.setProperty("--advanced-focus-pointer-y", `${pointerPositionRef.current.y}px`);
});
}
const current = activeRef.current;
if (!shiftHeldRef.current || current?.activation !== "preview") return;
if (event.target instanceof Element && event.target.closest("[data-advanced-focus-surface], [data-portal]")) {
cancelPreviewLeaveTimer();
return;
}
const isInsideSourceRect =
event.clientX >= current.sourceRect.left &&
event.clientX <= current.sourceRect.left + current.sourceRect.width &&
event.clientY >= current.sourceRect.top &&
event.clientY <= current.sourceRect.top + current.sourceRect.height;
if (isInsideSourceRect) {
cancelPreviewLeaveTimer();
return;
}
const underlyingSource = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) =>
element instanceof HTMLElement
? element.matches("[data-advanced-focus-source]")
? element
: element.closest<HTMLElement>("[data-advanced-focus-source]")
: null,
)
.find((source) => source !== null && source !== current.source);
const itemId = underlyingSource?.dataset.itemId;
if (!underlyingSource || !itemId) {
schedulePreviewClose(current.itemId);
return;
}
if (underlyingSource.hasAttribute("data-advanced-focus-enabled")) {
hover(itemId, underlyingSource);
return;
}
hoveredRef.current = null;
cancelHoverTimer();
schedulePreviewClose(current.itemId);
};
window.addEventListener("pointermove", handlePointerMove, { capture: true });
window.addEventListener("pointerover", handlePointerMove, { capture: true });
return () => {
window.removeEventListener("pointermove", handlePointerMove, { capture: true });
window.removeEventListener("pointerover", handlePointerMove, { capture: true });
if (pointerFrameRef.current !== null) cancelAnimationFrame(pointerFrameRef.current);
};
}, [cancelHoverTimer, cancelPreviewLeaveTimer, hover, schedulePreviewClose]);
const leave = useCallback(
(itemId: string) => {
if (hoveredRef.current?.itemId !== itemId) return;
hoveredRef.current = null;
cancelHoverTimer();
if (activeRef.current?.activation === "preview" && activeRef.current.itemId === itemId)
schedulePreviewClose(itemId);
},
[cancelHoverTimer, schedulePreviewClose],
);
const dismiss = useCallback(
(itemId: string) => {
if (activeRef.current?.itemId !== itemId) return;
cancelHoverTimer();
cancelPreviewLeaveTimer();
cancelCloseTimer();
updateActive(null);
},
[cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && activeRef.current) {
const advancedSurface = document.querySelector("[data-advanced-focus-surface]");
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
return;
event.preventDefault();
close();
return;
}
if (isEditMode) return;
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
if (activeRef.current?.activation === "manual") return;
shiftHeldRef.current = true;
startHoverTimer();
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key !== "Shift") return;
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
const handleBlur = () => {
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
cancelHoverTimer();
cancelPreviewLeaveTimer();
cancelCloseTimer();
};
}, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]);
useEffect(() => {
if (isEditMode && activeRef.current) close(false);
}, [close, isEditMode]);
useIsomorphicEffect(() => {
if (active?.activation !== "manual") return;
const background = document.querySelectorAll<HTMLElement>("[data-advanced-focus-background]");
background.forEach((element) => {
element.inert = true;
});
return () => {
background.forEach((element) => {
element.inert = false;
});
};
}, [active?.activation]);
const value = useMemo(
() => ({ active, viewportSize, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open, viewportSize],
);
return (
<AdvancedFocusContext.Provider value={value}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
);
};
export const useAdvancedFocus = () => {
const context = useContext(AdvancedFocusContext);
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
return context;
};
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from "vitest";
import { startAdvancedFocusEntrance } from "./entrance";
describe("startAdvancedFocusEntrance", () => {
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
});
test("moves the persistent portal before releasing its source-frame animation", () => {
let releaseFrame: FrameRequestCallback | undefined;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
releaseFrame = callback;
return 7;
}),
);
const compactHost = document.createElement("div");
const previewHost = document.createElement("div");
const portalTarget = document.createElement("div");
const surface = document.createElement("div");
surface.classList.add("surface-ready");
portalTarget.append(surface);
compactHost.append(portalTarget);
document.body.append(compactHost, previewHost);
expect(startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready")).toBe(7);
expect(portalTarget.parentElement).toBe(previewHost);
expect(surface.classList.contains("surface-ready")).toBe(false);
releaseFrame?.(0);
expect(surface.classList.contains("surface-ready")).toBe(true);
});
test("does not release a detached surface", () => {
let releaseFrame: FrameRequestCallback | undefined;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
releaseFrame = callback;
return 8;
}),
);
const previewHost = document.createElement("div");
const portalTarget = document.createElement("div");
const surface = document.createElement("div");
portalTarget.append(surface);
document.body.append(previewHost);
startAdvancedFocusEntrance(portalTarget, previewHost, surface, "surface-ready");
portalTarget.remove();
releaseFrame?.(0);
expect(surface.classList.contains("surface-ready")).toBe(false);
});
});
@@ -0,0 +1,13 @@
export const startAdvancedFocusEntrance = (
portalTarget: HTMLElement,
host: HTMLElement,
surface: HTMLElement,
readyClass: string,
) => {
host.append(portalTarget);
surface.classList.remove(readyClass);
return requestAnimationFrame(() => {
if (surface.isConnected) surface.classList.add(readyClass);
});
};
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { isEscapeOwnedByNestedOverlay } from "./escape";
describe("isEscapeOwnedByNestedOverlay", () => {
test("allows Escape from the advanced surface itself", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const button = document.createElement("button");
surface.append(button);
expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false);
});
test("leaves Escape to a nested Mantine modal", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
const modal = document.createElement("section");
modal.setAttribute("role", "dialog");
const input = document.createElement("input");
modal.append(input);
expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true);
});
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
const overlay = document.createElement("div");
overlay.setAttribute("role", role);
const target = document.createElement("button");
overlay.append(target);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
const target = document.createElement("button");
target.setAttribute("data-mantine-stop-propagation", "true");
surface.append(target);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const target = document.createElement("button");
surface.append(target);
document.body.append(surface);
const menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList;
document.body.append(menu);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
menu.remove();
surface.remove();
});
test("does not mistake the compact widget source for a nested overlay", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
const source = document.createElement("button");
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
});
});
@@ -0,0 +1,19 @@
const escapeOwnerSelector = [
'[data-mantine-stop-propagation="true"]',
'[role="dialog"]',
'[role="menu"]',
'[role="listbox"]',
].join(", ");
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null;
if (escapeOwner !== null && escapeOwner !== advancedSurface) return true;
return Array.from(document.querySelectorAll(escapeOwnerSelector)).some(
(owner) =>
owner !== advancedSurface &&
owner.getAttribute("aria-hidden") !== "true" &&
owner.closest('[aria-hidden="true"]') === null &&
owner.getClientRects().length > 0,
);
};
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "./geometry";
describe("getAdvancedFocusRect", () => {
it("expands around the widget instead of centering a modal", () => {
const rect = getAdvancedFocusRect({ left: 450, top: 300, width: 300, height: 200 }, { width: 1200, height: 800 });
expect(rect).toEqual({ left: 200, top: 120, width: 800, height: 560 });
expect(rect.left + rect.width / 2).toBe(600);
expect(rect.top + rect.height / 2).toBe(400);
});
it("uses the available viewport on small screens", () => {
expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({
left: 8,
top: 68,
width: 374,
height: 624,
});
});
it("keeps controls below the fixed board header on short screens", () => {
const rect = getAdvancedFocusRect({ left: 1200, top: 700, width: 500, height: 500 }, { width: 1366, height: 768 });
expect(rect.left).toBeGreaterThanOrEqual(16);
expect(rect.left + rect.width).toBeLessThanOrEqual(1350);
expect(rect.top).toBeGreaterThanOrEqual(76);
expect(rect.top + rect.height).toBeLessThanOrEqual(752);
});
});
describe("getAdvancedFocusClosePosition", () => {
it("keeps the close control outside the surface when the right side has room", () => {
expect(getAdvancedFocusClosePosition({ left: 16, top: 76, width: 800, height: 560 }, 1280)).toEqual({
left: 824,
top: 76,
});
});
it("uses the left side when the surface is anchored to the right edge", () => {
expect(getAdvancedFocusClosePosition({ left: 464, top: 76, width: 800, height: 560 }, 1280)).toEqual({
left: 412,
top: 76,
});
});
it("falls back to the inside corner on narrow screens", () => {
expect(getAdvancedFocusClosePosition({ left: 8, top: 68, width: 374, height: 624 }, 390)).toEqual({
left: 330,
top: 76,
});
});
});
@@ -0,0 +1,54 @@
import { appShellHeaderHeight } from "../../layout/constants";
export interface FocusRect {
left: number;
top: number;
width: number;
height: number;
}
export interface FocusClosePosition {
left: number;
top: number;
}
const closeButtonSize = 44;
const closeButtonGap = 8;
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
const margin = viewport.width < 640 ? 8 : 16;
const availableWidth = Math.max(0, viewport.width - margin * 2);
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(800, availableWidth);
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(560, availableHeight);
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
const minimumTop = appShellHeaderHeight + margin;
const maximumLeft = Math.max(margin, viewport.width - margin - width);
const maximumTop = Math.max(minimumTop, viewport.height - margin - height);
return {
left: Math.min(maximumLeft, Math.max(margin, source.left - (width - source.width) / 2)),
top: Math.min(maximumTop, Math.max(minimumTop, source.top - (height - source.height) / 2)),
width,
height,
};
};
export const getAdvancedFocusClosePosition = (surface: FocusRect, viewportWidth: number): FocusClosePosition => {
const outsideOffset = closeButtonSize + closeButtonGap;
const surfaceRight = surface.left + surface.width;
if (viewportWidth - surfaceRight >= outsideOffset) {
return { left: surfaceRight + closeButtonGap, top: surface.top };
}
if (surface.left >= outsideOffset) {
return { left: surface.left - outsideOffset, top: surface.top };
}
return {
left: Math.max(0, Math.min(viewportWidth - closeButtonSize, surfaceRight - outsideOffset)),
top: surface.top + closeButtonGap,
};
};
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act, createElement, Fragment, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AdvancedFocusManualSurface } from "./manual-surface";
const sourceRect = { left: 100, top: 120, width: 240, height: 160 };
const rect = { left: 40, top: 80, width: 640, height: 480 };
const closePosition = { left: 688, top: 80 };
describe("AdvancedFocusManualSurface", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.unstubAllGlobals();
});
test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const Harness = () => {
const [opened, setOpened] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
return createElement(
Fragment,
null,
createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"),
createElement(
AdvancedFocusManualSurface,
{
opened,
phase: "visible",
id: "advanced-focus-test",
label: "Test widget advanced view",
closeLabel: "Close advanced view",
rect,
closePosition,
sourceRect,
radius: "md",
contentRef,
onClose: () => setOpened(false),
},
createElement("button", { type: "button" }, "First widget action"),
createElement("button", { type: "button" }, "Last widget action"),
),
);
};
await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness))));
const trigger = document.querySelector<HTMLButtonElement>("button");
expect(trigger).not.toBeNull();
trigger?.focus();
await act(async () => trigger?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
const dialog = document.querySelector<HTMLElement>("[role='dialog']");
const closeButton = dialog?.querySelector<HTMLButtonElement>("[aria-label='Close advanced view']");
const actions = dialog?.querySelectorAll<HTMLButtonElement>("button");
expect(dialog?.id).toBe("advanced-focus-test");
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(dialog?.closest("[data-portal]")).not.toBeNull();
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
"Test widget advanced view",
);
expect(document.activeElement).toBe(closeButton);
const lastAction = actions?.item((actions?.length ?? 1) - 1);
lastAction?.focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
expect(document.activeElement).toBe(closeButton);
closeButton?.focus();
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(lastAction);
const backdrop = document.querySelector<HTMLElement>("[data-advanced-focus-overlay]");
await act(async () => backdrop?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
expect(document.activeElement).toBe(trigger);
});
});
@@ -0,0 +1,101 @@
"use client";
import type { CSSProperties, PropsWithChildren, Ref } from "react";
import { Modal, VisuallyHidden } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import classes from "./advanced-focus.module.css";
import type { FocusClosePosition, FocusRect } from "./geometry";
interface AdvancedFocusManualSurfaceProps extends PropsWithChildren {
opened: boolean;
phase: "visible" | "closing";
id: string;
label: string;
closeLabel: string;
rect: FocusRect;
closePosition: FocusClosePosition;
sourceRect: FocusRect;
radius: string | number;
contentRef: Ref<HTMLDivElement>;
onClose: () => void;
}
export const AdvancedFocusManualSurface = ({
opened,
phase,
id,
label,
closeLabel,
rect,
closePosition,
sourceRect,
radius,
contentRef,
onClose,
children,
}: AdvancedFocusManualSurfaceProps) => {
const reduceMotion = useReducedMotion();
const transitionDuration = reduceMotion ? 0 : 180;
const geometryStyle = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
"--focus-translate-x": `${sourceRect.left - rect.left}px`,
"--focus-translate-y": `${sourceRect.top - rect.top}px`,
"--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1,
"--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1,
} as CSSProperties;
const relativeClosePosition = {
left: closePosition.left - rect.left,
top: closePosition.top - rect.top,
};
return (
<Modal.Root
opened={opened}
onClose={onClose}
radius={radius}
padding={0}
shadow="none"
zIndex="var(--homarr-z-index-widget-focus)"
closeOnEscape={false}
closeOnClickOutside
lockScroll
trapFocus
returnFocus
removeScrollProps={{ allowPinchZoom: true }}
transitionProps={{ duration: transitionDuration, transition: "fade" }}
>
<Modal.Overlay
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
className={`${classes.backdrop} ${classes.manualBackdrop} ${
phase === "closing" ? classes.backdropClosing : ""
}`}
/>
<Modal.Content
ref={contentRef}
id={id}
data-advanced-focus-surface
className={`${classes.manualSurface} ${phase === "closing" ? classes.surfaceClosing : ""}`}
style={geometryStyle}
>
<Modal.Title>
<VisuallyHidden>{label}</VisuallyHidden>
</Modal.Title>
<Modal.CloseButton
className={classes.closeButton}
variant="default"
size={44}
aria-label={closeLabel}
aria-controls={id}
style={relativeClosePosition}
/>
{children}
</Modal.Content>
</Modal.Root>
);
};
@@ -0,0 +1,26 @@
const isVerticalScrollContainer = (element: Element) => {
if (element.scrollHeight <= element.clientHeight) return false;
const overflowY = getComputedStyle(element).overflowY;
return overflowY === "auto" || overflowY === "scroll";
};
const canScrollVertically = (element: Element, delta: number) => {
return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight;
};
export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => {
if (delta === 0) return false;
let current = target instanceof Element ? target : null;
while (current && root.contains(current)) {
const isCurrentScrollContainer = isVerticalScrollContainer(current);
if (isCurrentScrollContainer && canScrollVertically(current, delta)) {
current.scrollTop += delta;
return true;
}
if (current === root) break;
current = current.parentElement;
}
return false;
};
@@ -1,10 +1,12 @@
import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import { getRootSectionLane } from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -19,17 +21,21 @@ export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.filter(
(section): section is EmptySection =>
section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const itemLayouts = createItemLayouts(previous, firstSection, kind);
const widget = {
id: id ?? createId(),
kind,
options,
layouts: createItemLayouts(previous, firstSection, kind),
layouts: itemLayouts,
integrationIds,
advancedOptions: {
title: null,
@@ -60,9 +66,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width;
const size = {
width: Math.min(itemSize.width, columnCount),
height: itemSize.height,
};
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? getFirstEmptyPosition(elements, columnCount, undefined, size)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
}
return {
width: itemSize.width,
height: itemSize.height,
width: size.width,
height: size.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -1,6 +1,8 @@
import { createId } from "@homarr/common";
import { getRootSectionLane } from "@homarr/definitions";
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
@@ -15,16 +17,17 @@ export const duplicateItemCallback =
if (!itemToDuplicate) return previous;
const clonedItem = structuredClone(itemToDuplicate);
const clonedLayouts = clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
}));
return {
...previous,
items: previous.items.concat({
...clonedItem,
id: createId(),
layouts: clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
})),
layouts: clonedLayouts,
}),
};
};
@@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y
}
const firstSection = board.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) {
@@ -67,18 +72,21 @@ const getEmptySectionPosition = (
layout: ItemLayout,
section: Section,
): { xOffset: number; yOffset: number } | undefined => {
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId);
if (!boardLayout) return;
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
if (section.kind !== "dynamic") {
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId });
if (section.kind === "empty") {
const columnCount = getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset));
const size = {
width: layout.width,
height: layout.height,
});
};
return getFirstEmptyPosition(sectionElements, columnCount, undefined, size);
}
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId);
if (!sectionLayout) return;
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
@@ -1,8 +1,8 @@
import type { Board } from "~/app/[locale]/boards/_types";
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
const dynamicSectionsOfFirstSection = board.sections
.filter((section) => section.kind === "dynamic")
const containersOfSection = board.sections
.filter((section) => section.kind === "container")
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
.filter((section) => section.parentSectionId === sectionId);
@@ -14,5 +14,5 @@ export const getSectionElements = (board: Board, { sectionId, layoutId }: { sect
}))
.filter((item) => item.sectionId === sectionId);
return [...items, ...dynamicSectionsOfFirstSection];
return [...items, ...containersOfSection];
};
@@ -1,15 +1,19 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import { createItemCallback } from "../create-item";
import * as emptyPositionModule from "../empty-position";
import { BoardMockBuilder } from "./mocks/board-mock";
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
import { ContainerSectionMockBuilder } from "./mocks/container-section-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -52,9 +56,9 @@ describe("item actions create-item", () => {
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
@@ -63,21 +67,21 @@ describe("item actions create-item", () => {
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
const containerInFirstSection = new ContainerSectionMockBuilder({ id: "4" })
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
.build();
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
.build();
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherContainer = new ContainerSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
const board = new BoardMockBuilder()
.addLayout(layout)
.addEmptySection({ id: "1", yOffset: 2 })
.addEmptySection({ id: firstSectionId, yOffset: 0 })
.addEmptySection({ id: "3", yOffset: 1 })
.addSection(dynamicSectionInFirstSection)
.addSection(otherDynamicSection)
.addSection(containerInFirstSection)
.addSection(otherContainer)
.addItem(itemInFirstSection)
.addItem(otherItem)
.build();
@@ -94,8 +98,10 @@ describe("item actions create-item", () => {
// Assert
expect(result.items.length).toBe(3);
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
expect(item).toEqual(
const createdItem = result.items.find(
(candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id,
);
expect(createdItem).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
@@ -104,8 +110,35 @@ describe("item actions create-item", () => {
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
9999,
undefined,
{ height: 1, width: 1 },
);
});
test("clamps wide defaults and places below a full automatic canvas", () => {
const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build();
const layout = board.layouts[0];
if (!layout) throw new Error("Expected a board layout");
layout.id = "layout";
layout.columnCount = 1;
board.items.push(
new ItemMockBuilder({ id: "existing" })
.addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 })
.build(),
);
const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board);
const created = result.items.find((item) => item.id === "created");
expect(created?.layouts).toEqual([
expect.objectContaining({
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 3,
xOffset: 0,
yOffset: 1,
}),
]);
});
});
Loaded 100 of 618 files, more files were not shown because too many files have changed in this diff. Show more