Compare commits

...

262 Commits

Author SHA1 Message Date
e2fcdce154 1.18.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 46s
2026-04-01 19:14:03 +04:00
829dff9839 Merge pull request 'Connect OSM account' (#40) from feature/osm_auth into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 47s
Reviewed-on: #40
2026-04-01 15:12:46 +00:00
e7dfed204e Connect OSM account
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 49s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 18:46:19 +04:00
2dfd411837 Merge pull request 'Improve user menu layout, icons' (#39) from feature/user_menu into master
Some checks failed
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
Reviewed-on: #39
2026-04-01 13:33:07 +00:00
5afece5f51 Add nostrich icons for future use
All checks were successful
CI / Lint (pull_request) Successful in 45s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 17:16:56 +04:00
f1979282bd Use map icon for OSM in user menu 2026-04-01 17:16:36 +04:00
04181d5772 Use actual remoteStorage icon in user menu 2026-04-01 15:56:31 +04:00
b4ab1e926d Keep icon imports sorted properly 2026-04-01 15:56:16 +04:00
9b83d35b40 User menu: Move connection status to respective account type 2026-04-01 15:36:29 +04:00
beda03c2ac Update status doc 2026-04-01 14:42:07 +04:00
ecbac12440 Add ember-best-practices skill 2026-04-01 12:59:41 +04:00
913d5c915c 1.17.2
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-28 16:49:03 +04:00
89f667b17e Add more icons 2026-03-28 16:48:14 +04:00
22d4ef8d96 Update Pinhead 2026-03-28 16:47:53 +04:00
b17793af9d 1.17.1
Some checks failed
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
2026-03-28 15:32:21 +04:00
dc9e0f210a Hide search result markers when result is selected 2026-03-28 15:30:27 +04:00
2b219fe0cf 1.17.0
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-27 15:17:16 +04:00
9fd6c4d64d Merge pull request 'When search requests fail, show error in toast notifications instead of empty search results' (#38) from feature/failed_requests into master
Some checks failed
CI / Lint (push) Successful in 28s
CI / Test (push) Failing after 44s
Reviewed-on: #38
2026-03-27 11:12:58 +00:00
8e5b2c7439 Fix lint error
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 15:05:56 +04:00
0f29430e1a When request retries exhaust, show error in toast notification
Some checks failed
CI / Lint (pull_request) Failing after 29s
CI / Test (pull_request) Failing after 44s
2026-03-27 15:01:04 +04:00
0059d89cc3 Add toast notifications 2026-03-27 15:00:36 +04:00
54e2766dc4 Merge pull request 'Add setting for hiding quick search buttons' (#36) from feature/settings_quick-search into master
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
Reviewed-on: #36
2026-03-27 10:09:18 +00:00
5978f67d48 Add setting for hiding quick search buttons
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 13:59:36 +04:00
d72e5f3de2 Merge pull request 'Add category search, and search result markers with icons' (#35) from feature/poi_type_search into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 47s
Reviewed-on: #35
2026-03-27 09:12:28 +00:00
582ab4f8b3 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 38s
2026-03-23 18:32:18 +04:00
0ac6db65cb Add parking icon
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Test (pull_request) Waiting to run
2026-03-23 18:21:41 +04:00
86b20fd474 Abort search requests when clearing search box
Also adds abort support for Photon queries
2026-03-23 18:07:29 +04:00
8478e00253 Add loading indicator for search queries 2026-03-23 17:50:21 +04:00
818ec35071 Ensure map marker clicks preserve search context
Fixes the back button just closing the sidebar and clearing the whole
search after having seleted a result via map marker
2026-03-23 16:42:32 +04:00
46605dbd32 Add more icons 2026-03-23 16:07:33 +04:00
bcc51efecc Add catch-alls for place icons, staring with shop 2026-03-23 15:12:07 +04:00
8bec4b978e Ignore certain public transport results in nearby search 2026-03-23 15:00:39 +04:00
cd9676047d Add more icons 2026-03-23 14:42:41 +04:00
a92b44ec13 Ensure nearby search isn't doing category search 2026-03-23 13:51:11 +04:00
0c2d1f8419 Don't include coffee places in restaurant search 2026-03-22 19:23:16 +04:00
bb77ed8337 Add more icons 2026-03-22 19:13:19 +04:00
438bf0c31c Add icons to search result markers 2026-03-22 14:05:49 +04:00
af57e7fe57 Add map markers for search results 2026-03-22 10:59:11 +04:00
9183e3c366 Cache category search results
And abort ongoing searches when there's a new query
2026-03-20 19:27:26 +04:00
7e98b6796c Integrate category search with search box 2026-03-20 18:56:18 +04:00
8e9beb16de WIP Integrate category search with search box 2026-03-20 18:39:51 +04:00
b083c1d001 feat(search): add category search support and sync with chips 2026-03-20 18:14:02 +04:00
4008a8c883 Use "Results" header for category search results 2026-03-20 17:59:11 +04:00
eb7cff7ff5 Add tests for category quick search 2026-03-20 17:49:54 +04:00
db6478e353 Clear category param when typing new search 2026-03-20 17:42:36 +04:00
b39d92b7c4 Fix lint errors 2026-03-20 17:30:49 +04:00
aa99e5d766 Add icons for all quick search categories 2026-03-20 17:26:03 +04:00
5fd4ebe184 Centrally define filled icons
So we don't have to manually pass the option everywhere
2026-03-20 16:55:19 +04:00
f2a2d910a0 WIP Search places by category 2026-03-20 16:43:57 +04:00
6b37508f66 Merge pull request 'Disable edit button while editing' (#34) from ui/edit_button into master
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 1m0s
Reviewed-on: #34
2026-03-18 15:13:15 +00:00
8106009677 Disable edit button while editing
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 19:09:59 +04:00
07489c43a4 Merge pull request 'Add Pinhead iconset, icon for cuisine' (#33) from feature/pinhead_icons into master
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 1m0s
Reviewed-on: #33
2026-03-18 14:57:22 +00:00
a4e375cb51 Add Pinhead info to FOSS table in About section
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 18:34:49 +04:00
b680769eac Replace "cuisine" with icon in place details 2026-03-18 18:17:17 +04:00
4a609c8388 Add pinhead icons
https://pinhead.ink
2026-03-18 18:16:47 +04:00
cfcaaea3ec Update status doc
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
2026-03-18 17:42:50 +04:00
2f440d4971 1.16.0
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
2026-03-18 14:48:49 +04:00
1c6cbe6b0f Merge pull request 'Update OSM data when opening saved places' (#32) from feature/update_place_data into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #32
2026-03-18 10:46:33 +00:00
bdd5db157c Update OSM data when opening saved places
All checks were successful
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 14:42:15 +04:00
f7c40095d5 1.15.4
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 58s
2026-03-17 20:08:37 +04:00
579892067e Tweak sidebar width 2026-03-17 20:07:49 +04:00
48f87f98d6 Remove obsolete styles
All checks were successful
CI / Lint (push) Successful in 51s
CI / Test (push) Successful in 57s
2026-03-17 16:59:54 +04:00
3cd1b32af9 1.15.3
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:55:31 +04:00
462404b53e Fix sidebar content padding, class name 2026-03-17 16:54:41 +04:00
e3147caa90 1.15.2
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 59s
2026-03-17 16:35:10 +04:00
7c11fefdb7 Tweak whitespace 2026-03-17 16:34:19 +04:00
9af6636971 1.15.1
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:30:18 +04:00
8ae7fd1fd8 Tweak font sizes
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 56s
2026-03-17 16:25:55 +04:00
cb3fbade39 Prevent unwanted zoom on iOS
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 56s
2026-03-17 16:17:16 +04:00
6dfd9765b4 1.15.0
All checks were successful
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 56s
2026-03-17 15:55:15 +04:00
45eb8f087d Add linting to preversion script 2026-03-17 15:53:58 +04:00
3630fae133 Merge pull request 'Improve app menu sidebar, add separate subsections, more content' (#31) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #31
2026-03-17 11:52:42 +00:00
1116161e93 Add info/links for preferred contributions
All checks were successful
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 15:48:11 +04:00
88eb0ac0c1 WIP About section
* Add fold-out sections for additional details
* Use table for Open Source info, add licenses
* Move link styles to CSS variables
2026-03-17 15:12:29 +04:00
6da004e199 Merge pull request 'Increase sidebar width, improve auto-panning' (#30) from ui/autopan into feature/app_menu
Reviewed-on: #30
2026-03-17 09:20:46 +00:00
8877a9e1c8 Remove unnecessary whitespace
All checks were successful
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 13:18:17 +04:00
03d6a86569 Increase sidebar width, improve auto-panning
Some checks failed
CI / Lint (pull_request) Failing after 54s
CI / Test (pull_request) Has been cancelled
* Increase sidebar with on desktop to fit more content
* Move sidebar width to CSS variable
* Auto-pan when a selected place would be obstructed by the desktop
  sidebar
* Consider the header obscuring selected places, too
* Only autopan when actually necessary
2026-03-17 13:14:21 +04:00
5baebbb846 Add details elements/sections to App Menu sidebar
Starting with some About details
2026-03-16 18:23:46 +04:00
dca2991754 Style dropdown menu form controls 2026-03-16 17:07:38 +04:00
aee7f9d181 Move Photon API URL to Settings 2026-03-16 17:07:24 +04:00
56a077cceb Change release drafter token config
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
Another day, another try
2026-03-16 16:24:40 +04:00
7e5a034cac Merge pull request 'Prevent app icon loading when opening the app menu' (#29) from dev/app-icon into master
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 56s
Reviewed-on: #29
2026-03-16 12:06:49 +00:00
5892bd0cda Prevent app icon loading when opening the app menu
Some checks failed
CI / Lint (pull_request) Successful in 50s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Has been cancelled
Compile into app code by importing the icon in JS.
2026-03-16 16:02:57 +04:00
baaf027900 Fix missing token
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Hopefully
2026-03-14 16:57:52 +04:00
beb3d12169 Merge pull request 'Refactor app menu sidebar' (#28) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #28
2026-03-14 12:40:53 +00:00
a5aa396411 Merge branch 'master' into feature/app_menu
Some checks failed
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Failing after 17s
CI / Lint (pull_request) Successful in 48s
2026-03-14 16:31:42 +04:00
21cb8e6cc2 Add release drafter workflow
All checks were successful
CI / Lint (push) Successful in 54s
CI / Test (push) Successful in 1m8s
2026-03-14 16:31:22 +04:00
12ec7fcbbf Add release drafter config
Some checks failed
CI / Lint (push) Successful in 52s
CI / Test (push) Has been cancelled
2026-03-14 16:30:23 +04:00
78d7aeba2c Refactor app menu sidebar
* Rename to "app menu" across code
* Move content to dedicated sections/components, introduce app menu links
* Use CSS variable for hover background color across the app
2026-03-14 16:24:36 +04:00
3b71531de2 1.14.0
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 37s
2026-03-14 13:06:07 +04:00
6ef7549ea9 Merge pull request 'Add places to default lists' (#27) from feature/1-lists into master
All checks were successful
CI / Lint (push) Successful in 25s
CI / Test (push) Successful in 38s
Reviewed-on: #27
2026-03-14 09:04:21 +00:00
9097c63a55 Upgrade places module to latest release
All checks were successful
CI / Lint (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 42s
... with lists support
2026-03-14 12:36:49 +04:00
ec0d5a30f9 Extract icon imports to separate util
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 43s
So icons can be used from anywhere, e.g. map component JS
2026-03-14 12:28:17 +04:00
f1779131e8 Also load/init lists in anonymous mode
Some checks failed
CI / Lint (pull_request) Failing after 22s
CI / Test (pull_request) Successful in 34s
2026-03-13 17:04:29 +04:00
37cf47b3dd Properly handle place removals
Some checks failed
CI / Lint (pull_request) Failing after 23s
CI / Test (pull_request) Successful in 36s
* Transition to OSM route or index instead of staying on ghost route/ID
  (closes sidebar if it was a custom place)
* Ensure save button and lists are in the correct state
2026-03-13 15:33:29 +04:00
ff68b5addc Move default yellow to var, add in list UI 2026-03-13 14:56:12 +04:00
990f3afa88 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:51:49 +04:00
b2220b8310 Close list dropdown when clicking outside of it
Some checks failed
CI / Lint (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:40:28 +04:00
a8613ab81a Remove confirmation dialog when deleting place bookmarks 2026-03-13 13:27:01 +04:00
bcb9b20e85 WIP Add places to lists 2026-03-13 12:22:51 +04:00
466b1d5383 Comment dev config for remote access
All checks were successful
CI / Lint (push) Successful in 20s
CI / Test (push) Successful in 34s
2026-03-11 18:26:45 +04:00
ea7cb2f895 1.13.3
Some checks failed
CI / Lint (push) Failing after 18s
CI / Test (push) Successful in 29s
2026-03-11 18:19:15 +04:00
7e94f335ac Prevent zooming when selecting saved places 2026-03-11 18:16:24 +04:00
066ddb240d 1.13.2
Some checks failed
CI / Lint (push) Failing after 23s
CI / Test (push) Successful in 34s
2026-03-11 17:53:06 +04:00
df336b87ac Smart auto zoom for search/select 2026-03-11 17:51:26 +04:00
dbf71e366a Further improve scrolling 2026-03-11 17:19:48 +04:00
6a83003acb 1.13.1 2026-03-11 16:30:33 +04:00
bcc7c2a011 Improve bottom card scrolling on Android 2026-03-11 16:29:31 +04:00
19f04efecb 1.13.0 2026-03-11 16:16:57 +04:00
c79bbaa41a Merge pull request 'Add email, FB, Instagram to place details' (#26) from feature/social_links into master
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 34s
Reviewed-on: #26
2026-03-11 12:11:13 +00:00
b07640375a Add some white space to place details bottom
All checks were successful
CI / Lint (pull_request) Successful in 23s
CI / Test (pull_request) Successful in 39s
2026-03-11 16:07:37 +04:00
ffcb8219b0 Add email links 2026-03-11 15:22:34 +04:00
e01cb2ce6f Add Facebook and Instagram links 2026-03-11 15:02:47 +04:00
808c1ee37b 1.12.3
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 35s
2026-02-24 22:28:56 +04:00
34bc15cfa9 Only zoom out, not in, when fitting ways/relations 2026-02-24 22:27:52 +04:00
ee5e56910d 1.12.2
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 37s
2026-02-24 21:51:21 +04:00
e019fc2d6b Don't include roads and similar in nearby search
Meant to include bus stops, but have to be more specific when re-adding
2026-02-24 21:49:59 +04:00
9e03426b2e 1.12.1
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 33s
2026-02-24 20:49:35 +04:00
ecbf77c573 Add OpenGraph and Twitter Card metadata 2026-02-24 20:48:37 +04:00
703a5e8de0 1.12.0 2026-02-24 18:53:27 +04:00
b3c733769c Add app icon before app name in Settings header
All checks were successful
CI / Lint (push) Successful in 26s
CI / Test (push) Successful in 39s
2026-02-24 18:52:07 +04:00
60b2548efd Set up Gitea Actions/CI (#23)
All checks were successful
CI / Lint (push) Successful in 19s
CI / Test (push) Successful in 33s
Reviewed-on: #23
2026-02-24 14:23:01 +00:00
2e632658ad Fix warning 2026-02-24 16:43:06 +04:00
845be96b71 Fix linting errors, improve lint scripts 2026-02-24 16:31:22 +04:00
9ac4273fae Don't use outdated Overpass providers 2026-02-24 15:13:44 +04:00
3a825c3d6c Merge pull request 'Add full-text search' (#20) from feature/10-fulltext_search into master
Reviewed-on: #20
2026-02-24 11:03:57 +00:00
a6ca362876 Multi-line rendering for multi-value tags
E.g. opening hours, multiple phone numbers, ...
2026-02-24 14:50:39 +04:00
95e9c621a5 Fix sidebar link layout issue 2026-02-24 13:31:11 +04:00
e980431c17 Add Wikipedia icon, support for filled SVGs 2026-02-24 13:25:17 +04:00
4fdf2e2fb6 WIP Add more icons 2026-02-24 13:04:15 +04:00
de1b162ee9 Different sidebar headers for nearby and full search 2026-02-24 12:49:07 +04:00
1df77c2045 Tweak search box width on small screens 2026-02-24 12:21:03 +04:00
eb1445b749 Update status doc 2026-02-24 11:52:55 +04:00
316a38dbf8 Complete tests for localized names 2026-02-24 11:51:25 +04:00
7bcb572dbf If place key's value is "yes", display key instead
For example, building=yes with no other useful tags (e.g. amenity) will
show as Building now
2026-02-24 11:46:59 +04:00
d827fe263b Draw outlines/areas for ways and relations on map 2026-02-24 11:22:57 +04:00
1926e2b20c Switch back to more reliable Overpass default 2026-02-24 11:05:25 +04:00
df1f32d8bd More place type improvements 2026-02-24 11:05:02 +04:00
aa058bd7a3 Include places that only have localized names
For example "name" absent, but "name:en" present
2026-02-24 10:41:54 +04:00
361a826e4f Improve nearby search
* Use regular expression queries for place types
* Add more place types
* Add relations
* Only return results with a name
2026-02-24 09:58:12 +04:00
ff01d54fdd Update status doc 2026-02-23 23:28:11 +04:00
f73677139d Zoom to fit ways and relations into map view 2026-02-23 22:01:46 +04:00
8135695bba Add waterways (e.g. river) 2026-02-23 21:58:45 +04:00
8217e85836 Improve display of boundaries like cities, states, etc. 2026-02-23 21:14:40 +04:00
d9645d1a8c Fix search result subheadings
Should be address for non-nearby results
2026-02-23 20:28:03 +04:00
688e8eda8d Add more place types 2026-02-23 20:16:24 +04:00
323aab8256 Add more place types, refactor tag usage 2026-02-23 18:02:15 +04:00
ecb3fe4b5a Tweak sizes and layout for icon buttons 2026-02-23 16:50:34 +04:00
43b2700465 Don't start nearby search when unfocusing search by clicking map 2026-02-20 19:48:41 +04:00
00454c8fab Integrate the menu button in the search box
Allows us to make the search box wider, too
2026-02-20 18:35:01 +04:00
bf12305600 Add full-text search
Add a search box with a quick results popover, as well full results in
the sidebar on pressing enter.
2026-02-20 12:39:04 +04:00
2734f08608 Formatting 2026-02-20 12:38:57 +04:00
2aa59f9384 Fetch place details from OSM API, support relations
* Much faster
* Has more place details, which allows us to locate relations, in
  addition to nodes and ways
2026-02-20 12:34:48 +04:00
bcf8ca4255 Add service for Photon requests 2026-02-19 16:28:07 +04:00
20f63065ad 1.11.4 2026-02-10 19:21:34 +04:00
39a7ec3595 Improve code based on linting 2026-02-10 19:20:38 +04:00
32dfa3a30f Merge pull request 'Prefer place name in UA/browser language' (#19) from feature/16-place_names into master
Reviewed-on: #19
2026-02-10 15:20:23 +00:00
64ccc694d3 Prefer place name in UA/browser language
closes #16
2026-02-10 19:19:36 +04:00
87e2380ef6 1.11.3 2026-02-10 18:53:21 +04:00
66c31b19f1 Merge pull request 'UI improvements' (#18) from feature/location_improvements into master
Reviewed-on: #18
2026-02-10 14:52:22 +00:00
55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
585837cae7 1.10.1 2026-01-27 13:47:09 +07:00
42c5282844 Don't show GMaps link for private bookmarks 2026-01-27 13:46:43 +07:00
8a0603c65e 1.10.0 2026-01-27 13:38:33 +07:00
8e3187f38d Improve place-create button 2026-01-27 13:37:59 +07:00
a73e5cda6a Clean up code comments 2026-01-27 13:25:54 +07:00
0212fa359b Change console statements to debug or warn 2026-01-27 12:58:36 +07:00
8c58a76030 Create new places
And find them in search
2026-01-27 12:58:23 +07:00
a10f87290a Update status doc 2026-01-27 11:32:21 +07:00
e7b3b72e2f 1.9.0 2026-01-27 11:22:37 +07:00
399ad1822d Humanize place type properly, refactor for other tags 2026-01-27 11:21:51 +07:00
104a742543 Use dark grey for all text, change theme color 2026-01-27 11:00:06 +07:00
a8dc4c81e4 Implement simple query cache for Overpass/OSM search
So when we return to the search route, we don't have to refetch
2026-01-27 09:50:41 +07:00
156280950f Refactor search results with dedicated route 2026-01-27 09:50:26 +07:00
41d61be42e 1.8.10 2026-01-27 08:55:23 +07:00
06b47d96a7 Fix search results scrolling behavior 2026-01-27 08:54:42 +07:00
e8af959be6 Improve search results layout/styling 2026-01-27 08:54:38 +07:00
254e177cbf Update README 2026-01-26 19:55:18 +07:00
47fbc8e7cf Use published places module 2026-01-26 18:12:29 +07:00
4ad0df22e2 1.8.9 2026-01-26 17:53:09 +07:00
0decb4cf1b Optimize animations on iOS 2026-01-26 17:52:41 +07:00
2193f935cc Change default center and zoom to show the world on desktop 2026-01-26 17:52:14 +07:00
b2b03c0a38 1.8.8 2026-01-26 17:20:49 +07:00
0be02c5b20 Update status doc 2026-01-26 17:06:39 +07:00
653e44348c Fix auto-zoom when focussing form field on iOS 2026-01-26 17:01:29 +07:00
8fdc697a17 1.8.7 2026-01-26 16:46:34 +07:00
d9b2a17b91 Noto serif or no serif 2026-01-26 16:46:07 +07:00
85255318ba 1.8.6 2026-01-26 16:32:43 +07:00
713d9d53e6 Styling optimizations 2026-01-26 16:32:16 +07:00
e0ea0ca988 Prevent mobile Safari from resizing text 2026-01-26 16:23:46 +07:00
3cc2a2649a 1.8.5 2026-01-26 16:17:53 +07:00
924484a191 Set base font size explicitly 2026-01-26 16:16:47 +07:00
b960ba0868 Unify button styles, improve sizing 2026-01-26 16:15:52 +07:00
3b22f8c2f4 1.8.4 2026-01-26 16:03:06 +07:00
1a643e980d Fix location of additional map controls 2026-01-26 16:02:23 +07:00
b085783ad8 1.8.3 2026-01-26 14:58:21 +07:00
245f79d6f4 Change manifest filename
Wrong content type on 5apps
2026-01-26 14:57:36 +07:00
06beb73068 1.8.2 2026-01-26 14:43:47 +07:00
c3185b6a5a Change theme and manifest bg color 2026-01-26 14:43:13 +07:00
b6484aee9d Improve settings sidebar 2026-01-26 14:42:56 +07:00
ace2697de5 Add ember-cli as dev dependency
So we can run generators etc.
2026-01-26 14:23:25 +07:00
14e02f3641 Complete package.json 2026-01-26 14:23:16 +07:00
5a14db5601 Improve README layout 2026-01-26 13:54:54 +07:00
af1b4e92ac Add LICENSE 2026-01-26 13:48:42 +07:00
4cfee75a7c Improve README, add app icon 2026-01-26 13:48:30 +07:00
c61fad07c6 Document system dependencies for icon generation 2026-01-26 13:34:24 +07:00
9ce29807fd 1.8.1 2026-01-26 13:31:13 +07:00
c659dcc2d4 Deployment instructions 2026-01-26 13:30:38 +07:00
4bdd25c9c3 Add npm script for building icon versions from SVG source
`pnpm build:icons`
2026-01-26 13:30:02 +07:00
0f3359f725 1.8.0 2026-01-26 13:17:02 +07:00
25081f9cfc Add app icon, web app manifest 2026-01-26 13:16:24 +07:00
2efb15041e Update status doc 2026-01-24 21:15:58 +07:00
da64ae1572 1.7.0 2026-01-24 21:07:40 +07:00
1a96f95c82 Hide settings pane on outside click, render above places pane 2026-01-24 21:06:50 +07:00
911e6ddf38 Add setting for Overpass API provider 2026-01-24 20:47:55 +07:00
e61dc00725 Restore some lost styles 2026-01-24 20:47:42 +07:00
25d45a62c3 1.6.1 2026-01-24 18:00:47 +07:00
76dd8cdf24 Comment for app settings 2026-01-24 18:00:23 +07:00
269a6c9eef 1.6.0 2026-01-24 17:55:00 +07:00
1a2aae631d Fix JS linting errors 2026-01-24 17:54:34 +07:00
94b7959fd8 Fix CSS linting, organize properly 2026-01-24 17:47:37 +07:00
9082fb9762 Fix template linting errors 2026-01-24 16:42:53 +07:00
90730a935d Update status doc 2026-01-24 16:33:07 +07:00
0f44f42c23 Add settings/about pane 2026-01-24 16:18:39 +07:00
0d5a0325f4 Allow editing of bookmarks/places 2026-01-24 16:15:48 +07:00
e8f7e74e40 WIP Add settings/about pane 2026-01-24 14:33:00 +07:00
f60dacac80 1.5.0 2026-01-24 14:16:40 +07:00
9a02363515 Move all map controls to bottom right corner 2026-01-24 14:16:08 +07:00
f28be0c994 Add user/accounts menu, RS connect 2026-01-24 13:51:29 +07:00
721fe5f01d Fix linting/formatting 2026-01-24 12:52:19 +07:00
518685b7dc Improve secondary btn style 2026-01-24 11:17:07 +07:00
262e5b61a8 1.4.3 2026-01-23 16:52:21 +07:00
f87d8bdda9 Improve save button styles 2026-01-23 16:51:53 +07:00
f17f8ca17b Use feather icons in sidebar header 2026-01-23 15:49:59 +07:00
026d1c4712 1.4.2 2026-01-23 12:59:37 +07:00
6bd55843bb Switch to bkero's API (for now) 2026-01-23 12:59:11 +07:00
33a6469a19 Various layout and style improvements for place details 2026-01-23 12:41:27 +07:00
6d7bea411a 1.4.1 2026-01-23 10:21:25 +07:00
7b01bb1118 Fix place store/remove behavior 2026-01-23 10:21:02 +07:00
84d4f9cbbf 1.4.0 2026-01-22 17:35:06 +07:00
f7e7480e51 Pan map to bring loaded place into view if necessary 2026-01-22 17:34:19 +07:00
6e87ef3573 Load all saved place into memory
Fixes launching the app with a place URL directly, and will be useful
for search etc. later.
2026-01-22 17:23:50 +07:00
86b85e9a0b Ignore release dir for linting etc. 2026-01-22 16:52:26 +07:00
2a203e8e82 Add initialSyncDone property to storage service
Allows us to know when the first sync cycle has been completed
2026-01-22 16:40:02 +07:00
b08dcedd13 Slightly brighter icon color 2026-01-22 16:39:26 +07:00
5267ffdd5c Log map features on click 2026-01-22 14:54:01 +07:00
deae2260b1 Fix occasional exception on mobiles 2026-01-22 14:40:35 +07:00
3c5b4d9b98 Update status doc 2026-01-21 22:00:34 +07:00
187 changed files with 32374 additions and 692 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
# Ember.js Best Practices
A structured repository for creating and maintaining Ember.js Best Practices optimized for agents and LLMs.
## Structure
- `rules/` - Individual rule files (one per rule)
- `_sections.md` - Section metadata (titles, impacts, descriptions)
- `_template.md` - Template for creating new rules
- `area-description.md` - Individual rule files
- `metadata.json` - Document metadata (version, organization, abstract)
- **`AGENTS.md`** - Compiled output (generated)
- **`SKILL.md`** - Skill definition for Claude Code
## Rule Categories
Rules are organized by prefix:
- `route-` for Route Loading and Data Fetching (Section 1)
- `bundle-` for Build and Bundle Optimization (Section 2)
- `component-` for Component and Reactivity (Section 3)
- `a11y-` for Accessibility Best Practices (Section 4)
- `service-` for Service and State Management (Section 5)
- `template-` for Template Optimization (Section 6)
- `advanced-` for Advanced Patterns (Section 7)
## Rule File Structure
Each rule file should follow this structure:
````markdown
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
```javascript
// Bad code example
```
**Correct (description of what's right):**
```javascript
// Good code example
```
Optional explanatory text after examples.
Reference: [Link](https://example.com)
````
## File Naming Convention
- Files starting with `_` are special (excluded from build)
- Rule files: `area-description.md` (e.g., `route-parallel-model.md`)
- Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
## Impact Levels
- `CRITICAL` - Highest priority, major performance gains
- `HIGH` - Significant performance improvements
- `MEDIUM-HIGH` - Moderate-high gains
- `MEDIUM` - Moderate performance improvements
- `LOW-MEDIUM` - Low-medium gains
- `LOW` - Incremental improvements
## Contributing
When adding or modifying rules:
1. Use the correct filename prefix for your section
2. Follow the `_template.md` structure
3. Include clear bad/good examples with explanations
4. Add appropriate tags
5. Rules are automatically sorted by title - no need to manage numbers!
## Accessibility Focus
This guide emphasizes Ember's strong accessibility ecosystem:
- **ember-a11y-testing** - Automated testing with axe-core
- **ember-a11y** - Route announcements and focus management
- **ember-focus-trap** - Modal focus trapping
- **ember-page-title** - Accessible page titles
- **Semantic HTML** - Proper use of native elements
- **ARIA attributes** - When custom elements are needed
- **Keyboard navigation** - Full keyboard support patterns
## Acknowledgments
Built for the Ember.js community, drawing from official guides, Octane patterns, and accessibility best practices.

View File

@@ -0,0 +1,161 @@
---
name: ember-best-practices
description: Ember.js performance optimization and accessibility guidelines. This skill should be used when writing, reviewing, or refactoring Ember.js code to ensure optimal performance patterns and accessibility. Triggers on tasks involving Ember components, routes, data fetching, bundle optimization, or accessibility improvements.
license: MIT
metadata:
author: Ember.js Community
version: '1.0.0'
---
# Ember.js Best Practices
Comprehensive performance optimization and accessibility guide for Ember.js applications. Contains 58 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new Ember components or routes
- Implementing data fetching with WarpDrive
- Reviewing code for performance issues
- Refactoring existing Ember.js code
- Optimizing bundle size or load times
- Implementing accessibility features
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
| -------- | ------------------------------- | ----------- | ------------------------ |
| 1 | Route Loading and Data Fetching | CRITICAL | `route-` |
| 2 | Build and Bundle Optimization | CRITICAL | `bundle-` |
| 3 | Component and Reactivity | HIGH | `component-`, `exports-` |
| 4 | Accessibility Best Practices | HIGH | `a11y-` |
| 5 | Service and State Management | MEDIUM-HIGH | `service-` |
| 6 | Template Optimization | MEDIUM | `template-`, `helper-` |
| 7 | Performance Optimization | MEDIUM | `performance-` |
| 8 | Testing Best Practices | MEDIUM | `testing-` |
| 9 | Tooling and Configuration | MEDIUM | `vscode-` |
| 10 | Advanced Patterns | MEDIUM-HIGH | `advanced-` |
## Quick Reference
### 1. Route Loading and Data Fetching (CRITICAL)
- `route-parallel-model` - Use RSVP.hash() for parallel data loading
- `route-loading-substates` - Implement loading substates for better UX
- `route-lazy-routes` - Use route-based code splitting with Embroider
- `route-templates` - Use route templates with co-located syntax
- `route-model-caching` - Implement smart route model caching
### 2. Build and Bundle Optimization (CRITICAL)
- `bundle-direct-imports` - Import directly, avoid entire namespaces
- `bundle-embroider-static` - Enable Embroider static mode for tree-shaking
- `bundle-lazy-dependencies` - Lazy load heavy dependencies
### 3. Component and Reactivity Optimization (HIGH)
- `component-use-glimmer` - Use Glimmer components over classic components
- `component-cached-getters` - Use @cached for expensive computations
- `component-minimal-tracking` - Only track properties that affect rendering
- `component-tracked-toolbox` - Use tracked-built-ins for complex state
- `component-composition-patterns` - Use yield blocks and contextual components
- `component-reactive-chains` - Build reactive chains with dependent getters
- `component-class-fields` - Use class fields for component composition
- `component-controlled-forms` - Implement controlled form patterns
- `component-on-modifier` - Use {{on}} modifier for event handling
- `component-args-validation` - Validate component arguments
- `component-memory-leaks` - Prevent memory leaks in components
- `component-strict-mode` - Use strict mode and template-only components
- `component-avoid-classes-in-examples` - Avoid unnecessary classes in component examples
- `component-avoid-constructors` - Avoid constructors in Glimmer components
- `component-avoid-lifecycle-hooks` - Avoid legacy lifecycle hooks
- `component-file-conventions` - Follow proper file naming conventions
- `exports-named-only` - Use named exports only
### 4. Accessibility Best Practices (HIGH)
- `a11y-automated-testing` - Use ember-a11y-testing for automated checks
- `a11y-semantic-html` - Use semantic HTML and proper ARIA attributes
- `a11y-keyboard-navigation` - Ensure full keyboard navigation support
- `a11y-form-labels` - Associate labels with inputs, announce errors
- `a11y-route-announcements` - Announce route transitions to screen readers
### 5. Service and State Management (MEDIUM-HIGH)
- `service-cache-responses` - Cache API responses in services
- `service-shared-state` - Use services for shared state
- `service-ember-data-optimization` - Optimize WarpDrive queries
- `service-owner-linkage` - Manage service owner and linkage patterns
- `service-data-requesting` - Implement robust data requesting patterns
### 6. Template Optimization (MEDIUM)
- `template-let-helper` - Use {{#let}} to avoid recomputation
- `template-each-key` - Use {{#each}} with @key for efficient list updates
- `template-avoid-computation` - Move expensive work to cached getters
- `template-helper-imports` - Import helpers directly in templates
- `template-conditional-rendering` - Optimize conditional rendering
- `template-fn-helper` - Use {{fn}} helper for partial application
- `template-only-component-functions` - Use template-only components
- `helper-composition` - Compose helpers for reusable logic
- `helper-builtin-functions` - Use built-in helpers effectively
- `helper-plain-functions` - Write helpers as plain functions
### 7. Performance Optimization (MEDIUM)
- `performance-on-modifier-vs-handlers` - Use {{on}} modifier instead of event handler properties
### 8. Testing Best Practices (MEDIUM)
- `testing-modern-patterns` - Use modern testing patterns
- `testing-qunit-dom-assertions` - Use qunit-dom for better test assertions
- `testing-test-waiters` - Use @ember/test-waiters for async testing
- `testing-render-patterns` - Use correct render patterns for components
- `testing-msw-setup` - Mock API requests with MSW
- `testing-library-dom-abstraction` - Use Testing Library patterns
### 9. Tooling and Configuration (MEDIUM)
- `vscode-setup-recommended` - VS Code extensions and MCP server setup
### 10. Advanced Patterns (MEDIUM-HIGH)
- `advanced-modifiers` - Use modifiers for DOM side effects
- `advanced-helpers` - Extract reusable logic into helpers
- `advanced-tracked-built-ins` - Use reactive collections from @ember/reactive/collections
- `advanced-concurrency` - Use ember-concurrency for task management
- `advanced-data-loading-with-ember-concurrency` - Data loading patterns with ember-concurrency
## How to Use
Read individual rule files for detailed explanations and code examples:
```
rules/route-parallel-model.md
rules/bundle-embroider-static.md
rules/a11y-automated-testing.md
```
Each rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
## Accessibility with OSS Tools
Ember has excellent accessibility support through community addons:
- **ember-a11y-testing** - Automated accessibility testing in your test suite
- **ember-a11y** - Route announcements and focus management
- **ember-focus-trap** - Focus trapping for modals and dialogs
- **ember-page-title** - Accessible page title management
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
These tools, combined with native web platform features, provide comprehensive a11y support with minimal configuration.
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`

View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -e
OUTPUT="AGENTS.md"
RULES_DIR="rules"
# Start with the header
cat > "$OUTPUT" << 'HEADER'
# Ember Best Practices
Comprehensive performance optimization and accessibility patterns for modern Ember.js applications. Includes rules across 7 categories using gjs/gts format and modern Ember patterns.
---
HEADER
# Add sections
cat "$RULES_DIR/_sections.md" >> "$OUTPUT"
echo "" >> "$OUTPUT"
echo "---" >> "$OUTPUT"
echo "" >> "$OUTPUT"
# Add all rules
for file in "$RULES_DIR"/*.md; do
# Skip the _sections.md file
if [[ "$(basename "$file")" == "_sections.md" ]]; then
continue
fi
echo "Adding $(basename "$file")..." >&2
cat "$file" >> "$OUTPUT"
echo "" >> "$OUTPUT"
echo "---" >> "$OUTPUT"
echo "" >> "$OUTPUT"
done
echo "Built $OUTPUT successfully!" >&2

View File

@@ -0,0 +1,57 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
When multiple prefixes map to one section, all supported prefixes are listed.
---
## 1. Route Loading and Data Fetching (route)
**Impact:** CRITICAL
**Description:** Efficient route loading and parallel data fetching eliminate waterfalls. Using route model hooks effectively and loading data in parallel yields the largest performance gains.
## 2. Build and Bundle Optimization (bundle)
**Impact:** CRITICAL
**Description:** Using Embroider with static build optimizations, route-based code splitting, and proper imports reduces bundle size and improves Time to Interactive.
## 3. Component and Reactivity Optimization (component, exports)
**Impact:** HIGH
**Description:** Proper use of Glimmer components, modern file conventions, tracked properties, and avoiding unnecessary recomputation improves rendering performance.
## 4. Accessibility Best Practices (a11y)
**Impact:** HIGH
**Description:** Making applications accessible is critical. Use ember-a11y-testing, semantic HTML, proper ARIA attributes, and keyboard navigation support.
## 5. Service and State Management (service)
**Impact:** MEDIUM-HIGH
**Description:** Efficient service patterns, proper dependency injection, and state management reduce redundant computations and API calls.
## 6. Template Optimization (template, helper)
**Impact:** MEDIUM
**Description:** Optimizing templates with proper helpers, avoiding expensive computations in templates, and using {{#each}} efficiently improves rendering speed.
## 7. Performance Optimization (performance)
**Impact:** MEDIUM
**Description:** Performance-focused rendering and event handling patterns help reduce unnecessary work in hot UI paths.
## 8. Testing Best Practices (testing)
**Impact:** MEDIUM
**Description:** Modern testing patterns, waiters, and abstraction utilities improve test reliability and maintainability.
## 9. Tooling and Configuration (vscode)
**Impact:** MEDIUM
**Description:** Consistent editor setup and tooling recommendations improve team productivity and reduce environment drift.
## 10. Advanced Patterns (advanced)
**Impact:** MEDIUM-HIGH
**Description:** Modern Ember patterns including Resources for lifecycle management, ember-concurrency for async operations, modifiers for DOM side effects, helpers for reusable logic, and comprehensive testing patterns with render strategies.

View File

@@ -0,0 +1,28 @@
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description of impact (e.g., "20-50% improvement")
tags: tag1, tag2
---
## Rule Title Here
**Impact: MEDIUM (optional impact description)**
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```glimmer-ts
// Bad code example here
const bad = example();
```
**Correct (description of what's right):**
```glimmer-ts
// Good code example here
const good = example();
```
Reference: [Link to documentation or resource](https://example.com)

View File

@@ -0,0 +1,74 @@
---
title: Use ember-a11y-testing for Automated Checks
impact: HIGH
impactDescription: Catch 30-50% of a11y issues automatically
tags: accessibility, a11y, testing, ember-a11y-testing
---
## Use ember-a11y-testing for Automated Checks
Integrate ember-a11y-testing into your test suite to automatically catch common accessibility violations during development. This addon uses axe-core to identify issues before they reach production.
**Incorrect (no accessibility testing):**
```glimmer-js
// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import UserForm from 'my-app/components/user-form';
module('Integration | Component | user-form', function (hooks) {
setupRenderingTest(hooks);
test('it submits the form', async function (assert) {
await render(<template><UserForm /></template>);
await fillIn('input', 'John');
await click('button');
assert.ok(true);
});
});
```
**Correct (with a11y testing):**
```glimmer-js
// tests/integration/components/user-form-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import UserForm from 'my-app/components/user-form';
module('Integration | Component | user-form', function (hooks) {
setupRenderingTest(hooks);
test('it submits the form', async function (assert) {
await render(<template><UserForm /></template>);
// Automatically checks for a11y violations
await a11yAudit();
await fillIn('input', 'John');
await click('button');
assert.ok(true);
});
});
```
**Setup (install and configure):**
```bash
ember install ember-a11y-testing
```
```javascript
// tests/test-helper.js
import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
setupGlobalA11yHooks(); // Runs on every test automatically
```
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)

View File

@@ -0,0 +1,147 @@
---
title: Form Labels and Error Announcements
impact: HIGH
impactDescription: Essential for screen reader users
tags: accessibility, a11y, forms, aria-live
---
## Form Labels and Error Announcements
All form inputs must have associated labels, and validation errors should be announced to screen readers using ARIA live regions.
**Incorrect (missing labels and announcements):**
```glimmer-js
// app/components/form.gjs
<template>
<form {{on "submit" this.handleSubmit}}>
<input type="email" value={{this.email}} {{on "input" this.updateEmail}} placeholder="Email" />
{{#if this.emailError}}
<span>{{this.emailError}}</span>
{{/if}}
<button type="submit">Submit</button>
</form>
</template>
```
**Correct (with labels and announcements):**
```glimmer-js
// app/components/form.gjs
<template>
<form {{on "submit" this.handleSubmit}}>
<div>
<label for="email-input">
Email Address
{{#if this.isEmailRequired}}
<span aria-label="required">*</span>
{{/if}}
</label>
<input
id="email-input"
type="email"
value={{this.email}}
{{on "input" this.updateEmail}}
aria-describedby={{if this.emailError "email-error"}}
aria-invalid={{if this.emailError "true"}}
required={{this.isEmailRequired}}
/>
{{#if this.emailError}}
<span id="email-error" role="alert" aria-live="polite">
{{this.emailError}}
</span>
{{/if}}
</div>
<button type="submit" disabled={{this.isSubmitting}}>
{{#if this.isSubmitting}}
<span aria-live="polite">Submitting...</span>
{{else}}
Submit
{{/if}}
</button>
</form>
</template>
```
**For complex forms, use platform-native validation with custom logic:**
```glimmer-js
// app/components/user-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class UserForm extends Component {
@tracked errorMessages = {};
validateEmail = (event) => {
// Custom business logic validation
const input = event.target;
const value = input.value;
if (!value) {
input.setCustomValidity('Email is required');
return false;
}
if (!input.validity.valid) {
input.setCustomValidity('Must be a valid email');
return false;
}
// Additional custom validation (e.g., check if email is already taken)
if (value === 'taken@example.com') {
input.setCustomValidity('This email is already registered');
return false;
}
input.setCustomValidity('');
return true;
};
handleSubmit = async (event) => {
event.preventDefault();
const form = event.target;
// Run custom validations
const emailInput = form.querySelector('[name="email"]');
const fakeEvent = { target: emailInput };
this.validateEmail(fakeEvent);
// Use native validation check
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
await this.args.onSubmit(formData);
};
<template>
<form {{on "submit" this.handleSubmit}}>
<label for="user-email">
Email
<input
id="user-email"
type="email"
name="email"
required
value={{@user.email}}
{{on "blur" this.validateEmail}}
/>
</label>
<button type="submit">Save</button>
</form>
</template>
}
```
Always associate labels with inputs and announce dynamic changes to screen readers using aria-live regions.
Reference: [Ember Accessibility - Application Considerations](https://guides.emberjs.com/release/accessibility/application-considerations/)

View File

@@ -0,0 +1,163 @@
---
title: Keyboard Navigation Support
impact: HIGH
impactDescription: Critical for keyboard-only users
tags: accessibility, a11y, keyboard, focus-management
---
## Keyboard Navigation Support
Ensure all interactive elements are keyboard accessible and focus management is handled properly, especially in modals and dynamic content.
**Incorrect (no keyboard support):**
```glimmer-js
// app/components/dropdown.gjs
<template>
<div class="dropdown" {{on "click" this.toggleMenu}}>
Menu
{{#if this.isOpen}}
<div class="dropdown-menu">
<div {{on "click" this.selectOption}}>Option 1</div>
<div {{on "click" this.selectOption}}>Option 2</div>
</div>
{{/if}}
</div>
</template>
```
**Correct (full keyboard support with custom modifier):**
```javascript
// app/modifiers/focus-first.js
import { modifier } from 'ember-modifier';
export default modifier((element, [selector = 'button']) => {
// Focus first matching element when modifier runs
element.querySelector(selector)?.focus();
});
```
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import focusFirst from '../modifiers/focus-first';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggleMenu() {
this.isOpen = !this.isOpen;
}
@action
handleButtonKeyDown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.isOpen = true;
}
}
@action
handleMenuKeyDown(event) {
if (event.key === 'Escape') {
this.isOpen = false;
// Return focus to button
event.target.closest('.dropdown').querySelector('button').focus();
}
// Handle arrow key navigation between menu items
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.moveFocus(event.key === 'ArrowDown' ? 1 : -1);
}
}
moveFocus(direction) {
const items = Array.from(document.querySelectorAll('[role="menuitem"] button'));
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex]?.focus();
}
@action
selectOption(value) {
this.args.onSelect?.(value);
this.isOpen = false;
}
<template>
<div class="dropdown">
<button
type="button"
{{on "click" this.toggleMenu}}
{{on "keydown" this.handleButtonKeyDown}}
aria-haspopup="true"
aria-expanded="{{this.isOpen}}"
>
Menu
</button>
{{#if this.isOpen}}
<ul
class="dropdown-menu"
role="menu"
{{focusFirst '[role="menuitem"] button'}}
{{on "keydown" this.handleMenuKeyDown}}
>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "1")}}>
Option 1
</button>
</li>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "2")}}>
Option 2
</button>
</li>
</ul>
{{/if}}
</div>
</template>
}
```
**For focus trapping in modals, use ember-focus-trap:**
```bash
ember install ember-focus-trap
```
```glimmer-js
// app/components/modal.gjs
import FocusTrap from 'ember-focus-trap/components/focus-trap';
<template>
{{#if this.showModal}}
<FocusTrap @isActive={{true}} @initialFocus="#modal-title">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{{@title}}</h2>
{{yield}}
<button type="button" {{on "click" this.closeModal}}>Close</button>
</div>
</FocusTrap>
{{/if}}
</template>
```
**Alternative: Use libraries for keyboard support:**
For complex keyboard interactions, consider using libraries that abstract keyboard support patterns:
```bash
npm install @fluentui/keyboard-keys
```
Or use [tabster](https://tabster.io/) for comprehensive keyboard navigation management including focus trapping, arrow key navigation, and modalizers.
Proper keyboard navigation ensures all users can interact with your application effectively.
Reference: [Ember Accessibility - Keyboard](https://guides.emberjs.com/release/accessibility/keyboard/)

View File

@@ -0,0 +1,174 @@
---
title: Announce Route Transitions to Screen Readers
impact: HIGH
impactDescription: Critical for screen reader navigation
tags: accessibility, a11y, routing, screen-readers
---
## Announce Route Transitions to Screen Readers
Announce page title changes and route transitions to screen readers so users know when navigation has occurred.
**Incorrect (no announcements):**
```javascript
// app/router.js
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
```
**Correct (using a11y-announcer library - recommended):**
Use the [a11y-announcer](https://github.com/ember-a11y/a11y-announcer) library for robust route announcements:
```bash
ember install @ember-a11y/a11y-announcer
```
```javascript
// app/router.js
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('about');
this.route('dashboard');
this.route('posts', function () {
this.route('post', { path: '/:post_id' });
});
});
```
The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service announcer;
afterModel() {
this.announcer.announce('Loaded dashboard with latest data');
}
}
```
**Alternative: DIY approach with ARIA live regions:**
If you prefer not to use a library, you can implement route announcements yourself:
```javascript
// app/router.js
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('about');
this.route('dashboard');
this.route('posts', function () {
this.route('post', { path: '/:post_id' });
});
});
```
```javascript
// app/routes/application.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ApplicationRoute extends Route {
@service router;
constructor() {
super(...arguments);
this.router.on('routeDidChange', (transition) => {
// Update document title
const title = this.getPageTitle(transition.to);
document.title = title;
// Announce to screen readers
this.announceRouteChange(title);
});
}
getPageTitle(route) {
// Get title from route metadata or generate it
return route.metadata?.title || route.name;
}
announceRouteChange(title) {
const announcement = document.getElementById('route-announcement');
if (announcement) {
announcement.textContent = `Navigated to ${title}`;
}
}
}
```
```glimmer-js
// app/routes/application.gjs
<template>
<div
id="route-announcement"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
{{outlet}}
</template>
```
```css
/* app/styles/app.css */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
```
**Alternative: Use ember-page-title with announcements:**
```bash
ember install ember-page-title
```
```glimmer-js
// app/routes/dashboard.gjs
import { pageTitle } from 'ember-page-title';
<template>
{{pageTitle "Dashboard"}}
<div class="dashboard">
{{outlet}}
</div>
</template>
```
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)

View File

@@ -0,0 +1,102 @@
---
title: Semantic HTML and ARIA Attributes
impact: HIGH
impactDescription: Essential for screen reader users
tags: accessibility, a11y, semantic-html, aria
---
## Semantic HTML and ARIA Attributes
Use semantic HTML elements and proper ARIA attributes to make your application accessible to screen reader users. **The first rule of ARIA is to not use ARIA** - prefer native semantic HTML elements whenever possible.
**Key principle:** Native HTML elements have built-in keyboard support, roles, and behaviors. Only add ARIA when semantic HTML can't provide the needed functionality.
**Incorrect (divs with insufficient semantics):**
```glimmer-js
// app/components/example.gjs
<template>
<div class="button" {{on "click" this.submit}}>
Submit
</div>
<div class="nav">
<div class="nav-item">Home</div>
<div class="nav-item">About</div>
</div>
<div class="alert">
{{this.message}}
</div>
</template>
```
**Correct (semantic HTML with proper ARIA):**
```glimmer-js
// app/components/example.gjs
import { LinkTo } from '@ember/routing';
<template>
<button type="submit" {{on "click" this.submit}}>
Submit
</button>
<nav aria-label="Main navigation">
<ul>
<li><LinkTo @route="index">Home</LinkTo></li>
<li><LinkTo @route="about">About</LinkTo></li>
</ul>
</nav>
<div role="alert" aria-live="polite" aria-atomic="true">
{{this.message}}
</div>
</template>
```
**For interactive custom elements:**
```glimmer-js
// app/components/custom-button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import XIcon from './x-icon';
class CustomButton extends Component {
@action
handleKeyDown(event) {
// Support Enter and Space keys for keyboard users
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick();
}
}
@action
handleClick() {
this.args.onClick?.();
}
<template>
<div
role="button"
tabindex="0"
{{on "click" this.handleClick}}
{{on "keydown" this.handleKeyDown}}
aria-label="Close dialog"
>
<XIcon />
</div>
</template>
}
```
Always use native semantic elements when possible. When creating custom interactive elements, ensure they're keyboard accessible and have proper ARIA attributes.
**References:**
- [ARIA Authoring Practices Guide (W3C)](https://www.w3.org/WAI/ARIA/apg/)
- [Using ARIA (W3C)](https://www.w3.org/TR/using-aria/)
- [ARIA in HTML (WHATWG)](https://html.spec.whatwg.org/multipage/aria.html#aria)
- [Ember Accessibility Guide](https://guides.emberjs.com/release/accessibility/)

View File

@@ -0,0 +1,198 @@
---
title: Use Ember Concurrency for User Input Concurrency
impact: HIGH
impactDescription: Better control of user-initiated async operations
tags: ember-concurrency, tasks, user-input, concurrency-patterns
---
## Use Ember Concurrency for User Input Concurrency
Use ember-concurrency for managing **user-initiated** async operations like search, form submission, and autocomplete. It provides automatic cancelation, debouncing, and prevents race conditions from user actions.
**Incorrect (manual async handling with race conditions):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Search extends Component {
@tracked results = [];
@tracked isSearching = false;
@tracked error = null;
currentRequest = null;
@action
async search(event) {
const query = event.target.value;
// Manual cancelation - easy to get wrong
if (this.currentRequest) {
this.currentRequest.abort();
}
this.isSearching = true;
this.error = null;
const controller = new AbortController();
this.currentRequest = controller;
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
this.results = await response.json();
} catch (e) {
if (e.name !== 'AbortError') {
this.error = e.message;
}
} finally {
this.isSearching = false;
}
}
<template>
<input {{on "input" this.search}} />
{{#if this.isSearching}}Loading...{{/if}}
{{#if this.error}}Error: {{this.error}}{{/if}}
</template>
}
```
**Correct (using ember-concurrency with task return values):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask } from 'ember-concurrency';
class Search extends Component {
// restartableTask automatically cancels previous searches
// IMPORTANT: Return the value, don't set tracked state inside tasks
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return, don't set @tracked
});
<template>
<input {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{#if this.searchTask.isRunning}}
<div class="loading">Loading...</div>
{{/if}}
{{#if this.searchTask.last.isSuccessful}}
<ul>
{{#each this.searchTask.last.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{#if this.searchTask.last.isError}}
<div class="error">{{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**With debouncing for user typing:**
```glimmer-js
// app/components/autocomplete.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
class Autocomplete extends Component {
searchTask = restartableTask(async (query) => {
// Debounce user typing - wait 300ms
await timeout(300);
const response = await fetch(`/api/autocomplete?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform (pick "target.value"))}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="spinner"></div>
{{/if}}
{{#if this.searchTask.lastSuccessful}}
<ul class="suggestions">
{{#each this.searchTask.lastSuccessful.value as |item|}}
<li>{{item.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
**Task modifiers for different user concurrency patterns:**
```glimmer-js
import Component from '@glimmer/component';
import { dropTask, enqueueTask, restartableTask } from 'ember-concurrency';
class FormActions extends Component {
// dropTask: Prevents double-click - ignores new while running
saveTask = dropTask(async (data) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
});
// enqueueTask: Queues user actions sequentially
processTask = enqueueTask(async (item) => {
const response = await fetch('/api/process', {
method: 'POST',
body: JSON.stringify(item),
});
return response.json();
});
// restartableTask: Cancels previous, starts new (for search)
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
});
<template>
<button {{on "click" (fn this.saveTask.perform @data)}} disabled={{this.saveTask.isRunning}}>
Save
</button>
</template>
}
```
**Key Principles for ember-concurrency:**
1. **User-initiated only** - Use for handling user actions, not component initialization
2. **Return values** - Use `task.last.value`, never set `@tracked` state inside tasks
3. **Avoid side effects** - Don't modify component state that's read during render inside tasks
4. **Choose right modifier**:
- `restartableTask` - User typing/search (cancel previous)
- `dropTask` - Form submit/save (prevent double-click)
- `enqueueTask` - Sequential processing (queue user actions)
**When NOT to use ember-concurrency:**
- ❌ Component initialization data loading (use `getPromiseState` instead)
- ❌ Setting tracked state inside tasks (causes infinite render loops)
- ❌ Route model hooks (return promises directly)
- ❌ Simple async without user concurrency concerns (use async/await)
See **advanced-data-loading-with-ember-concurrency.md** for correct data loading patterns.
ember-concurrency provides automatic cancelation, derived state (isRunning, isIdle), and better patterns for **user-initiated** async operations.
Reference: [ember-concurrency](https://ember-concurrency.com/)

View File

@@ -0,0 +1,243 @@
---
title: Use Ember Concurrency Correctly - User Concurrency Not Data Loading
impact: HIGH
impactDescription: Prevents infinite render loops and improves performance
tags: ember-concurrency, tasks, data-loading, anti-pattern
---
## Use Ember Concurrency Correctly - User Concurrency Not Data Loading
ember-concurrency is designed for **user-initiated concurrency patterns** (debouncing, throttling, preventing double-clicks), not data loading. Use task return values, don't set tracked state inside tasks.
**Incorrect (using ember-concurrency for data loading with tracked state):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
class UserProfile extends Component {
@tracked userData = null;
@tracked error = null;
// WRONG: Setting tracked state inside task
loadUserTask = task(async () => {
try {
const response = await fetch(`/api/users/${this.args.userId}`);
this.userData = await response.json(); // Anti-pattern!
} catch (e) {
this.error = e; // Anti-pattern!
}
});
<template>
{{#if this.loadUserTask.isRunning}}
Loading...
{{else if this.userData}}
<h1>{{this.userData.name}}</h1>
{{/if}}
</template>
}
```
**Why This Is Wrong:**
- Setting tracked state during render can cause infinite render loops
- ember-concurrency adds overhead unnecessary for simple data loading
- Makes component state harder to reason about
- Can trigger multiple re-renders
**Correct (use getPromiseState from warp-drive/reactiveweb for data loading):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class UserProfile extends Component {
@cached
get userData() {
const promise = fetch(`/api/users/${this.args.userId}`).then((r) => r.json());
return getPromiseState(promise);
}
<template>
{{#if this.userData.isPending}}
<div>Loading...</div>
{{else if this.userData.isRejected}}
<div>Error: {{this.userData.error.message}}</div>
{{else if this.userData.isFulfilled}}
<h1>{{this.userData.value.name}}</h1>
{{/if}}
</template>
}
```
**Correct (use ember-concurrency for USER input with derived data patterns):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { pick } from 'ember-composable-helpers';
class Search extends Component {
// CORRECT: For user-initiated search with debouncing
// Use derived data from TaskInstance API - lastSuccessful
searchTask = restartableTask(async (query) => {
await timeout(300); // Debounce user typing
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input type="search" {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{! Use derived data from task state - no tracked properties needed }}
{{#if this.searchTask.isRunning}}
<div>Searching...</div>
{{/if}}
{{! lastSuccessful persists previous results while new search runs }}
{{#if this.searchTask.lastSuccessful}}
<ul>
{{#each this.searchTask.lastSuccessful.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{! Show error from most recent failed attempt }}
{{#if this.searchTask.last.isError}}
<div>Error: {{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Good Use Cases for ember-concurrency:**
1. **User input debouncing** - prevent API spam from typing
2. **Form submission** - prevent double-click submits with `dropTask`
3. **Autocomplete** - restart previous searches as user types
4. **Polling** - user-controlled refresh intervals
5. **Multi-step wizards** - sequential async operations
```glimmer-js
// app/components/form-submit.gjs
import Component from '@glimmer/component';
import { dropTask } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
class FormSubmit extends Component {
// dropTask prevents double-submit - perfect for user actions
submitTask = dropTask(async (formData) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(formData),
});
return response.json();
});
<template>
<button
{{on "click" (fn this.submitTask.perform @formData)}}
disabled={{this.submitTask.isRunning}}
>
{{#if this.submitTask.isRunning}}
Saving...
{{else}}
Save
{{/if}}
</button>
{{! Use lastSuccessful for success message - derived data }}
{{#if this.submitTask.lastSuccessful}}
<div>Saved successfully!</div>
{{/if}}
{{#if this.submitTask.last.isError}}
<div>Error: {{this.submitTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Bad Use Cases for ember-concurrency:**
1. ❌ **Loading data on component init** - use `getPromiseState` instead
2. ❌ **Route model hooks** - just return promises directly
3. ❌ **Simple API calls** - async/await is sufficient
4. ❌ **Setting tracked state inside tasks** - causes render loops
**Key Principles:**
- **Derive data, don't set it** - Use `task.lastSuccessful`, `task.last`, `task.isRunning` (derived from TaskInstance API)
- **Use task return values** - Read from `task.lastSuccessful.value` or `task.last.value`, never set tracked state
- **User-initiated only** - ember-concurrency is for handling user concurrency patterns
- **Data loading** - Use `getPromiseState` from warp-drive/reactiveweb for non-user-initiated loading
- **Avoid side effects** - Don't modify component state inside tasks that's read during render
**TaskInstance API for Derived Data:**
ember-concurrency provides a powerful derived data API through Task and TaskInstance:
- `task.last` - The most recent TaskInstance (successful or failed)
- `task.lastSuccessful` - The most recent successful TaskInstance (persists during new attempts)
- `task.isRunning` - Derived boolean if any instance is running
- `taskInstance.value` - The returned value from the task
- `taskInstance.isError` - Derived boolean if this instance failed
- `taskInstance.error` - The error if this instance failed
This follows the **derived data pattern** - all state comes from the task itself, no tracked properties needed!
References:
- [TaskInstance API](https://ember-concurrency.com/api/TaskInstance.html)
- [Task API](https://ember-concurrency.com/api/Task.html)
**Migration from tracked state pattern:**
```glimmer-js
// BEFORE (anti-pattern - setting tracked state)
class Bad extends Component {
@tracked data = null;
fetchTask = task(async () => {
this.data = await fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.data}}
}
// AFTER (correct - using derived data from TaskInstance API)
class Good extends Component {
fetchTask = restartableTask(async () => {
return fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.fetchTask.lastSuccessful.value}}
// All state derived from task - no tracked properties!
}
// Or better yet, for non-user-initiated loading:
class Better extends Component {
@cached
get data() {
return getPromiseState(fetch('/api/data').then((r) => r.json()));
}
// template reads: {{#if this.data.isFulfilled}}{{this.data.value}}{{/if}}
}
```
ember-concurrency is a powerful tool for **user concurrency patterns**. For data loading, use `getPromiseState` instead.
Reference:
- [ember-concurrency](https://ember-concurrency.com/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)

View File

@@ -0,0 +1,156 @@
---
title: Use Helper Functions for Reusable Logic
impact: LOW-MEDIUM
impactDescription: Better code reuse and testability
tags: helpers, templates, reusability, advanced
---
## Use Helper Functions for Reusable Logic
Extract reusable template logic into helper functions that can be tested independently and used across templates.
**Incorrect (logic duplicated in components):**
```javascript
// app/components/user-card.js
class UserCard extends Component {
get formattedDate() {
const date = new Date(this.args.user.createdAt);
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
}
}
// app/components/post-card.js - same logic duplicated!
class PostCard extends Component {
get formattedDate() {
// Same implementation...
}
}
```
**Correct (reusable helper):**
For single-use helpers, keep them in the same file as the component:
```glimmer-js
// app/components/post-list.gjs
import Component from '@glimmer/component';
// Helper co-located in same file
function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
class PostList extends Component {
<template>
{{#each @posts as |post|}}
<article>
<h2>{{post.title}}</h2>
<time>{{formatRelativeDate post.createdAt}}</time>
</article>
{{/each}}
</template>
}
```
For helpers shared across multiple components in a feature, use a subdirectory:
```javascript
// app/components/blog/format-relative-date.js
export function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
```
**Alternative (shared helper in utils):**
For truly shared helpers used across the whole app, use `app/utils/`:
```javascript
// app/utils/format-relative-date.js
// Flat structure - use subpath-imports in package.json for nicer imports if needed
export function formatRelativeDate(date) {
const dateObj = new Date(date);
const now = new Date();
const diffMs = now - dateObj;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return dateObj.toLocaleDateString();
}
```
**Note**: Keep utils flat (`app/utils/format-relative-date.js`), not nested (`app/utils/date/format-relative-date.js`). If you need cleaner top-level imports, configure subpath-imports in package.json instead of nesting files.
```glimmer-js
// app/components/user-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';
<template>
<p>Joined: {{formatRelativeDate @user.createdAt}}</p>
</template>
```
```glimmer-js
// app/components/post-card.gjs
import { formatRelativeDate } from '../utils/format-relative-date';
<template>
<p>Posted: {{formatRelativeDate @post.createdAt}}</p>
</template>
```
**For helpers with state, use class-based helpers:**
```javascript
// app/utils/helpers/format-currency.js
export class FormatCurrencyHelper {
constructor(owner) {
this.intl = owner.lookup('service:intl');
}
compute(amount, { currency = 'USD' } = {}) {
return this.intl.formatNumber(amount, {
style: 'currency',
currency,
});
}
}
```
**Common helpers to create:**
- Date/time formatting
- Number formatting
- String manipulation
- Array operations
- Conditional logic
Helpers promote code reuse, are easier to test, and keep components focused on behavior.
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)

View File

@@ -0,0 +1,129 @@
---
title: Use Modifiers for DOM Side Effects
impact: LOW-MEDIUM
impactDescription: Better separation of concerns
tags: modifiers, dom, lifecycle, advanced
---
## Use Modifiers for DOM Side Effects
Use modifiers (element modifiers) to handle DOM side effects and lifecycle events in a reusable, composable way.
**Incorrect (manual DOM manipulation in component):**
```glimmer-js
// app/components/chart.gjs
import Component from '@glimmer/component';
class Chart extends Component {
chartInstance = null;
constructor() {
super(...arguments);
// Can't access element here - element doesn't exist yet!
}
willDestroy() {
super.willDestroy();
this.chartInstance?.destroy();
}
<template>
<canvas id="chart-canvas"></canvas>
{{! Manual setup is error-prone and not reusable }}
</template>
}
```
**Correct (function modifier - preferred for simple side effects):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
export default modifier((element, [config]) => {
// Initialize chart
const chartInstance = new Chart(element, config);
// Return cleanup function
return () => {
chartInstance.destroy();
};
});
```
**Also correct (class-based modifier for complex state):**
```javascript
// app/modifiers/chart.js
import Modifier from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default class ChartModifier extends Modifier {
chartInstance = null;
modify(element, [config]) {
// Cleanup previous instance if config changed
if (this.chartInstance) {
this.chartInstance.destroy();
}
this.chartInstance = new Chart(element, config);
// Register cleanup
registerDestructor(this, () => {
this.chartInstance?.destroy();
});
}
}
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
**Use function modifiers** for simple side effects. Use class-based modifiers only when you need complex state management.
**For commonly needed modifiers, use ember-modifier helpers:**
```javascript
// app/modifiers/autofocus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
```glimmer-js
// app/components/input-field.gjs
import autofocus from '../modifiers/autofocus';
<template><input {{autofocus}} type="text" /></template>
```
**Use ember-resize-observer-modifier for resize handling:**
```bash
ember install ember-resize-observer-modifier
```
```glimmer-js
// app/components/resizable.gjs
import onResize from 'ember-resize-observer-modifier';
<template>
<div {{onResize this.handleResize}}>
Content that responds to size changes
</div>
</template>
```
Modifiers provide a clean, reusable way to manage DOM side effects without coupling to specific components.
Reference: [Ember Modifiers](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)

View File

@@ -0,0 +1,277 @@
---
title: Use Reactive Collections from @ember/reactive/collections
impact: HIGH
impactDescription: Enables reactive arrays, maps, and sets
tags: reactivity, tracked, collections, advanced
---
## Use Reactive Collections from @ember/reactive/collections
Use reactive collections from `@ember/reactive/collections` to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.
**The Problem:**
Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.
**The Solution:**
Use Ember's built-in reactive collections from `@ember/reactive/collections`.
### Reactive Arrays
**Incorrect (non-reactive array):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TodoList extends Component {
@tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates
@action
addTodo(text) {
// This won't trigger a re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also won't trigger a re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
**Correct (reactive array with @ember/reactive/collections):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedArray } from '@ember/reactive/collections';
export default class TodoList extends Component {
todos = trackedArray([]); // ✅ Mutations are reactive
@action
addTodo(text) {
// Now this triggers re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also triggers re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
### Reactive Maps
Maps are useful for key-value stores with non-string keys:
```glimmer-js
// app/components/user-cache.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedMap } from '@ember/reactive/collections';
export default class UserCache extends Component {
userCache = trackedMap(); // key: userId, value: userData
@action
cacheUser(userId, userData) {
this.userCache.set(userId, userData);
}
@action
clearUser(userId) {
this.userCache.delete(userId);
}
get cachedUsers() {
return Array.from(this.userCache.values());
}
<template>
<ul>
{{#each this.cachedUsers as |user|}}
<li>{{user.name}}</li>
{{/each}}
</ul>
<p>Cache size: {{this.userCache.size}}</p>
</template>
}
```
### Reactive Sets
Sets are useful for unique collections:
```glimmer-js
// app/components/tag-selector.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedSet } from '@ember/reactive/collections';
export default class TagSelector extends Component {
selectedTags = trackedSet();
@action
toggleTag(tag) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
}
get selectedCount() {
return this.selectedTags.size;
}
<template>
<div>
{{#each @availableTags as |tag|}}
<label>
<input
type="checkbox"
checked={{this.selectedTags.has tag}}
{{on "change" (fn this.toggleTag tag)}}
/>
{{tag}}
</label>
{{/each}}
</div>
<p>Selected: {{this.selectedCount}} tags</p>
</template>
}
```
### When to Use Each Type
| Type | Use Case |
| -------------- | ------------------------------------------------------------------ |
| `trackedArray` | Ordered lists that need mutation methods (push, pop, splice, etc.) |
| `trackedMap` | Key-value pairs with non-string keys or when you need `size` |
| `trackedSet` | Unique values, membership testing |
### Common Patterns
**Initialize with data:**
```javascript
import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';
// Array
const todos = trackedArray([
{ id: 1, text: 'First' },
{ id: 2, text: 'Second' },
]);
// Map
const userMap = trackedMap([
[1, { name: 'Alice' }],
[2, { name: 'Bob' }],
]);
// Set
const tags = trackedSet(['javascript', 'ember', 'web']);
```
**Convert to plain JavaScript:**
```javascript
// Array
const plainArray = [...trackedArray];
const plainArray2 = Array.from(trackedArray);
// Map
const plainObject = Object.fromEntries(trackedMap);
// Set
const plainArray3 = [...trackedSet];
```
**Functional array methods still work:**
```javascript
const todos = trackedArray([...]);
// All of these work and are reactive
const completed = todos.filter(t => t.done);
const titles = todos.map(t => t.title);
const allDone = todos.every(t => t.done);
const firstIncomplete = todos.find(t => !t.done);
```
### Alternative: Immutable Updates
If you prefer immutability, you can use regular `@tracked` with reassignment:
```javascript
import { tracked } from '@glimmer/tracking';
export default class TodoList extends Component {
@tracked todos = [];
@action
addTodo(text) {
// Reassignment is reactive
this.todos = [...this.todos, { id: Date.now(), text }];
}
@action
removeTodo(id) {
// Reassignment is reactive
this.todos = this.todos.filter((t) => t.id !== id);
}
}
```
**When to use each approach:**
- Use reactive collections when you need mutable operations (better performance for large lists)
- Use immutable updates when you want simpler mental model or need history/undo
### Best Practices
1. **Don't mix approaches** - choose either reactive collections or immutable updates
2. **Initialize in class field** - no need for constructor
3. **Use appropriate type** - Map for key-value, Set for unique values, Array for ordered lists
4. **Export from modules** if shared across components
Reactive collections from `@ember/reactive/collections` provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.
**References:**
- [Ember Reactivity System](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
- [JavaScript Built-in Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects)
- [Reactive Collections RFC](https://github.com/emberjs/rfcs/blob/master/text/0869-reactive-collections.md)

View File

@@ -0,0 +1,62 @@
---
title: Avoid Importing Entire Addon Namespaces
impact: CRITICAL
impactDescription: 200-500ms import cost reduction
tags: bundle, imports, tree-shaking, performance
---
## Avoid Importing Entire Addon Namespaces
Import specific utilities and components directly rather than entire addon namespaces to enable better tree-shaking and reduce bundle size.
**Incorrect (imports entire namespace):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
// OK - these are already optimized
// But avoid this pattern with utility libraries:
import * as lodash from 'lodash';
import * as moment from 'moment';
class My extends Component {
someMethod() {
return lodash.debounce(this.handler, 300);
}
}
```
**Correct (direct imports):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import debounce from 'lodash/debounce';
import dayjs from 'dayjs'; // moment alternative, smaller
class My extends Component {
someMethod() {
return debounce(this.handler, 300);
}
}
```
**Even better (use Ember utilities when available):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { debounce } from '@ember/runloop';
class My extends Component {
someMethod() {
return debounce(this, this.handler, 300);
}
}
```
Direct imports and using built-in Ember utilities reduce bundle size by avoiding unused code.

View File

@@ -0,0 +1,69 @@
---
title: Use Embroider Build Pipeline
impact: CRITICAL
impactDescription: Modern build system with better performance
tags: bundle, embroider, build-performance, vite
---
## Use Embroider Build Pipeline
Use Embroider, Ember's modern build pipeline, with Vite for faster builds, better tree-shaking, and smaller bundles.
**Incorrect (classic build pipeline):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {});
return app.toTree();
};
```
**Correct (Embroider with Vite):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');
module.exports = async function (defaults) {
const { buildOnce } = await import('@embroider/vite');
let app = new EmberApp(defaults, {
// Add options here
});
return compatBuild(app, buildOnce);
};
```
**For stricter static analysis (optimized mode):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');
module.exports = async function (defaults) {
const { buildOnce } = await import('@embroider/vite');
let app = new EmberApp(defaults, {
// Add options here
});
return compatBuild(app, buildOnce, {
// Enable static analysis for better tree-shaking
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
staticHelpers: true,
staticModifiers: true,
staticComponents: true,
});
};
```
Embroider provides a modern build pipeline with Vite that offers faster builds and better optimization compared to the classic Ember CLI build system.
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)

View File

@@ -0,0 +1,71 @@
---
title: Lazy Load Heavy Dependencies
impact: CRITICAL
impactDescription: 30-50% initial bundle reduction
tags: bundle, lazy-loading, dynamic-imports, performance
---
## Lazy Load Heavy Dependencies
Use dynamic imports to load heavy libraries only when needed, reducing initial bundle size.
**Incorrect (loaded upfront):**
```javascript
import Component from '@glimmer/component';
import Chart from 'chart.js/auto'; // 300KB library loaded immediately
import hljs from 'highlight.js'; // 500KB library loaded immediately
class Dashboard extends Component {
get showChart() {
return this.args.hasData;
}
}
```
**Correct (lazy loaded with error/loading state handling):**
```glimmer-js
import Component from '@glimmer/component';
import { getPromiseState } from 'reactiveweb/promise';
class Dashboard extends Component {
// Use getPromiseState to model promise state for error/loading handling
chartLoader = getPromiseState(async () => {
const { default: Chart } = await import('chart.js/auto');
return Chart;
});
highlighterLoader = getPromiseState(async () => {
const { default: hljs } = await import('highlight.js');
return hljs;
});
loadChart = () => {
// Triggers lazy load, handles loading/error states automatically
return this.chartLoader.value;
};
highlightCode = (code) => {
const hljs = this.highlighterLoader.value;
if (hljs) {
return hljs.highlightAuto(code);
}
return code;
};
<template>
{{#if this.chartLoader.isLoading}}
<p>Loading chart library...</p>
{{else if this.chartLoader.isError}}
<p>Error loading chart: {{this.chartLoader.error.message}}</p>
{{else if this.chartLoader.isResolved}}
<canvas {{on "click" this.loadChart}}></canvas>
{{/if}}
</template>
}
```
**Note**: Always model promise state (loading/error/resolved) using `getPromiseState` from `reactiveweb/promise` to handle slow networks and errors properly.
Dynamic imports reduce initial bundle size by 30-50%, improving Time to Interactive.

View File

@@ -0,0 +1,174 @@
---
title: Validate Component Arguments
impact: MEDIUM
impactDescription: Better error messages and type safety
tags: components, validation, arguments, typescript
---
## Validate Component Arguments
Validate component arguments for better error messages, documentation, and type safety.
**Incorrect (no argument validation):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (with TypeScript signature):**
```glimmer-ts
// app/components/user-card.gts
import Component from '@glimmer/component';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
avatarUrl?: string;
};
onEdit?: (user: UserCardSignature['Args']['user']) => void;
};
Blocks: {
default: [];
};
Element: HTMLDivElement;
}
class UserCard extends Component<UserCardSignature> {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**Runtime validation with assertions (using getters):**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { assert } from '@ember/debug';
class DataTable extends Component {
// Use getters so validation runs on each access and catches arg changes
get columns() {
assert(
'DataTable requires @columns argument',
this.args.columns && Array.isArray(this.args.columns),
);
assert(
'@columns must be an array of objects with "key" and "label" properties',
this.args.columns.every((col) => col.key && col.label),
);
return this.args.columns;
}
get rows() {
assert('DataTable requires @rows argument', this.args.rows && Array.isArray(this.args.rows));
return this.args.rows;
}
<template>
<table>
<thead>
<tr>
{{#each this.columns as |column|}}
<th>{{column.label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.rows as |row|}}
<tr>
{{#each this.columns as |column|}}
<td>{{get row column.key}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</template>
}
```
**Template-only component with TypeScript:**
```glimmer-ts
// app/components/icon.gts
import type { TOC } from '@ember/component/template-only';
interface IconSignature {
Args: {
name: string;
size?: 'small' | 'medium' | 'large';
};
Element: HTMLSpanElement;
}
const Icon: TOC<IconSignature> = <template>
<span ...attributes></span>
</template>;
export default Icon;
```
**Documentation with JSDoc:**
```glimmer-js
// app/components/modal.gjs
import Component from '@glimmer/component';
/**
* Modal dialog component
*
* @param {Object} args
* @param {boolean} args.isOpen - Controls modal visibility
* @param {() => void} args.onClose - Called when modal should close
* @param {string} [args.title] - Optional modal title
* @param {string} [args.size='medium'] - Modal size: 'small', 'medium', 'large'
*/
class Modal extends Component {
<template>
{{#if @isOpen}}
<div>
{{#if @title}}
<h2>{{@title}}</h2>
{{/if}}
{{yield}}
<button {{on "click" @onClose}}>Close</button>
</div>
{{/if}}
</template>
}
```
Argument validation provides better error messages during development, serves as documentation, and enables better IDE support.
Reference: [TypeScript in Ember](https://guides.emberjs.com/release/typescript/)

View File

@@ -0,0 +1,174 @@
---
title: Avoid CSS Classes in Learning Examples
impact: LOW-MEDIUM
impactDescription: Cleaner, more focused learning materials
tags: documentation, examples, learning, css, classes
---
## Avoid CSS Classes in Learning Examples
Don't add CSS classes to learning content and examples unless they provide actual value above the surrounding context. Classes add visual noise and distract from the concepts being taught.
**Incorrect (unnecessary classes in learning example):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div class="user-card">
<div class="user-card__header">
<h3 class="user-card__name">{{@user.name}}</h3>
<p class="user-card__email">{{@user.email}}</p>
</div>
{{#if @user.avatarUrl}}
<img class="user-card__avatar" src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button class="user-card__edit-button" {{on "click" (fn @onEdit @user)}}>
Edit
</button>
{{/if}}
<div class="user-card__content">
{{yield}}
</div>
</div>
</template>
}
```
**Why This Is Wrong:**
- Classes add visual clutter that obscures the actual concepts
- Learners focus on naming conventions instead of the pattern being taught
- Makes copy-paste more work (need to remove or change class names)
- Implies these specific class names are required or best practice
- Distracts from structural HTML and component logic
**Correct (focused on concepts):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**Benefits:**
- **Clarity**: Easier to understand the component structure
- **Focus**: Reader attention stays on the concepts being taught
- **Simplicity**: Less code to process mentally
- **Flexibility**: Reader can add their own classes without conflict
- **Reusability**: Examples are easier to adapt to real code
**When Classes ARE Appropriate in Examples:**
```glimmer-js
// Example: Teaching about conditional classes
export class StatusBadge extends Component {
get statusClass() {
return this.args.status === 'active' ? 'badge-success' : 'badge-error';
}
<template>
<span class={{this.statusClass}}>
{{@status}}
</span>
</template>
}
```
```glimmer-js
// Example: Teaching about ...attributes for styling flexibility
export class Card extends Component {
<template>
{{! Caller can add their own classes via ...attributes }}
<div ...attributes>
{{yield}}
</div>
</template>
}
{{! Usage: <Card class="user-card">...</Card> }}
```
**When to Include Classes:**
1. **Teaching class binding** - Example explicitly about conditional classes or class composition
2. **Demonstrating ...attributes** - Showing how callers add classes
3. **Accessibility** - Using classes for semantic meaning (e.g., `aria-*` helpers)
4. **Critical to example** - Class name is essential to understanding (e.g., `selected`, `active`)
**Examples Where Classes Add Value:**
```glimmer-js
// Good: Teaching about dynamic classes
export class TabButton extends Component {
<template>
<button class={{if @isActive "active"}} {{on "click" @onClick}}>
{{yield}}
</button>
</template>
}
```
```glimmer-js
// Good: Teaching about class composition
import { cn } from 'ember-cn';
export class Button extends Component {
<template>
<button class={{cn "btn" (if @primary "btn-primary" "btn-secondary")}}>
{{yield}}
</button>
</template>
}
```
**Default Stance:**
When writing learning examples or documentation:
1. **Start without classes** - Add them only if needed
2. **Ask**: Does this class help explain the concept?
3. **Remove** any decorative or structural classes that aren't essential
4. **Use** `...attributes` to show styling flexibility
**Real-World Context:**
In production code, you'll have classes for styling. But in learning materials, strip them away unless they're teaching something specific about classes themselves.
**Common Violations:**
❌ BEM classes in examples (`user-card__header`)
❌ Utility classes unless teaching utilities (`flex`, `mt-4`)
❌ Semantic classes that don't teach anything (`container`, `wrapper`)
❌ Design system classes unless teaching design system integration
**Summary:**
Keep learning examples focused on the concept being taught. CSS classes should appear only when they're essential to understanding the pattern or when demonstrating styling flexibility with `...attributes`.
Reference: [Ember Components Guide](https://guides.emberjs.com/release/components/)

View File

@@ -0,0 +1,162 @@
---
title: Avoid Constructors in Components
impact: HIGH
impactDescription: Prevents infinite render loops and simplifies code
tags: components, constructors, initialization, anti-pattern
---
## Avoid Constructors in Components
**Strongly discourage constructor usage.** Modern Ember components rarely need constructors. Use class fields, @service decorators, and getPromiseState for initialization instead. Constructors with function calls that set tracked state can cause infinite render loops.
**Incorrect (using constructor):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
class UserProfile extends Component {
constructor() {
super(...arguments);
// Anti-pattern: Manual service lookup
this.store = this.owner.lookup('service:store');
this.router = this.owner.lookup('service:router');
// Anti-pattern: Imperative initialization
this.data = null;
this.isLoading = false;
this.error = null;
// Anti-pattern: Side effects in constructor
this.loadUserData();
}
async loadUserData() {
this.isLoading = true;
try {
this.data = await this.store.request({
url: `/users/${this.args.userId}`,
});
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
}
<template>
{{#if this.isLoading}}
<div>Loading...</div>
{{else if this.error}}
<div>Error: {{this.error.message}}</div>
{{else if this.data}}
<h1>{{this.data.name}}</h1>
{{/if}}
</template>
}
```
**Correct (use class fields and declarative async state):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { service } from '@ember/service';
import { getRequestState } from '@warp-drive/ember';
class UserProfile extends Component {
@service store;
@cached
get userRequest() {
return this.store.request({
url: `/users/${this.args.userId}`,
});
}
<template>
{{#let (getRequestState this.userRequest) as |state|}}
{{#if state.isPending}}
<div>Loading...</div>
{{else if state.isError}}
<div>Error loading user</div>
{{else}}
<h1>{{state.value.name}}</h1>
{{/if}}
{{/let}}
</template>
}
```
**When You Might Need a Constructor (Very Rare):**
Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns:
```glimmer-js
// app/components/complex-setup.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
class ComplexSetup extends Component {
@service store;
@tracked state = null;
constructor(owner, args) {
super(owner, args);
// Only if you absolutely must do something that can't be done with class fields
// Even then, prefer resources or modifiers
if (this.args.legacyInitMode) {
this.initializeLegacyMode();
}
}
initializeLegacyMode() {
// Rare edge case initialization
}
<template>
<!-- template -->
</template>
}
```
**Why Strongly Avoid Constructors:**
1. **Infinite Render Loops**: Setting tracked state in constructor that's read during render causes infinite loops
2. **Service Injection**: Use `@service` decorator instead of `owner.lookup()`
3. **Testability**: Class fields are easier to mock and test
4. **Clarity**: Declarative class fields show state at a glance
5. **Side Effects**: getPromiseState and modifiers handle side effects better
6. **Memory Leaks**: getPromiseState auto-cleanup; constructor code doesn't
7. **Reactivity**: Class fields integrate better with tracking
8. **Initialization Order**: No need to worry about super() call timing
9. **Argument Validation**: Constructor validation runs only once; use getters to catch arg changes
**Modern Alternatives:**
| Old Pattern | Modern Alternative |
| -------------------------------------------------------------- | -------------------------------------------------------- |
| `constructor() { this.store = owner.lookup('service:store') }` | `@service store;` |
| `constructor() { this.data = null; }` | `@tracked data = null;` |
| `constructor() { this.loadData(); }` | Use `@cached get` with getPromiseState |
| `constructor() { this.interval = setInterval(...) }` | Use modifier with registerDestructor |
| `constructor() { this.subscription = ... }` | Use modifier or constructor with registerDestructor ONLY |
**Performance Impact:**
- **Before**: Constructor runs on every instantiation, manual cleanup risk, infinite loop danger
- **After**: Class fields initialize efficiently, getPromiseState auto-cleanup, no render loops
**Strongly discourage constructors** - they add complexity and infinite render loop risks. Use declarative class fields and getPromiseState instead.
Reference:
- [Ember Octane Guide](https://guides.emberjs.com/release/upgrading/current-edition/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)

View File

@@ -0,0 +1,322 @@
---
title: Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
impact: HIGH
impactDescription: Prevents memory leaks and enforces modern patterns
tags: components, lifecycle, anti-pattern, modifiers, derived-data
---
## Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers.
### Why These Are Problematic
1. **Memory Leaks**: Easy to forget cleanup, especially with `did-insert`
2. **Tight Coupling**: Mixes template concerns with JavaScript logic
3. **Poor Testability**: Lifecycle hooks are harder to unit test
4. **Not Composable**: Can't be easily shared across components
5. **Deprecated Pattern**: Not recommended in modern Ember
### Alternative 1: Use Derived Data
For computed values or reactive transformations, use getters and `@cached`:
**❌ Incorrect (did-update):**
```glimmer-js
// app/components/user-greeting.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class UserGreeting extends Component {
@tracked displayName = '';
@action
updateDisplayName() {
// Runs on every render - inefficient and error-prone
this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div {{did-update this.updateDisplayName @user}}>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ Correct (derived data with getter):**
```glimmer-js
// app/components/user-greeting.gjs
import Component from '@glimmer/component';
class UserGreeting extends Component {
// Automatically reactive - updates when args change
get displayName() {
return `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ Even better (use @cached for expensive computations):**
```glimmer-js
// app/components/user-stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class UserStats extends Component {
@cached
get sortedPosts() {
// Expensive computation only runs when @posts changes
return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt);
}
@cached
get statistics() {
return {
total: this.args.posts.length,
published: this.args.posts.filter((p) => p.published).length,
drafts: this.args.posts.filter((p) => !p.published).length,
};
}
<template>
<div>
<p>Total: {{this.statistics.total}}</p>
<p>Published: {{this.statistics.published}}</p>
<p>Drafts: {{this.statistics.drafts}}</p>
<ul>
{{#each this.sortedPosts as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</div>
</template>
}
```
### Alternative 2: Use Custom Modifiers
For DOM side effects, element setup, or cleanup, use custom modifiers:
**❌ Incorrect (did-insert + will-destroy):**
```glimmer-js
// app/components/chart.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Chart extends Component {
chartInstance = null;
@action
setupChart(element) {
this.chartInstance = new Chart(element, this.args.config);
}
willDestroy() {
super.willDestroy();
// Easy to forget cleanup!
this.chartInstance?.destroy();
}
<template>
<canvas {{did-insert this.setupChart}}></canvas>
</template>
}
```
**✅ Correct (custom modifier with automatic cleanup):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default modifier((element, [config]) => {
// Setup
const chartInstance = new Chart(element, config);
// Cleanup happens automatically
registerDestructor(element, () => {
chartInstance.destroy();
});
});
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
### Alternative 3: Use Resources for Lifecycle Management
For complex state management with automatic cleanup, use `ember-resources`:
**❌ Incorrect (did-insert for data fetching):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class UserProfile extends Component {
@tracked userData = null;
@tracked loading = true;
controller = new AbortController();
@action
async loadUser() {
this.loading = true;
try {
const response = await fetch(`/api/users/${this.args.userId}`, {
signal: this.controller.signal,
});
this.userData = await response.json();
} finally {
this.loading = false;
}
}
willDestroy() {
super.willDestroy();
this.controller.abort(); // Easy to forget!
}
<template>
<div {{did-insert this.loadUser}}>
{{#if this.loading}}
Loading...
{{else}}
{{this.userData.name}}
{{/if}}
</div>
</template>
}
```
**✅ Correct (Resource with automatic cleanup):**
```javascript
// app/resources/user-data.js
import { Resource } from 'ember-resources';
import { tracked } from '@glimmer/tracking';
export default class UserDataResource extends Resource {
@tracked data = null;
@tracked loading = true;
controller = new AbortController();
modify(positional, named) {
const [userId] = positional;
this.loadUser(userId);
}
async loadUser(userId) {
this.loading = true;
try {
const response = await fetch(`/api/users/${userId}`, {
signal: this.controller.signal,
});
this.data = await response.json();
} finally {
this.loading = false;
}
}
willDestroy() {
// Cleanup happens automatically
this.controller.abort();
}
}
```
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import UserDataResource from '../resources/user-data';
class UserProfile extends Component {
userData = UserDataResource.from(this, () => [this.args.userId]);
<template>
{{#if this.userData.loading}}
Loading...
{{else}}
{{this.userData.data.name}}
{{/if}}
</template>
}
```
### When to Use Each Alternative
| Use Case | Solution | Why |
| ---------------- | ----------------------------------- | ----------------------------------------- |
| Computed values | Getters + `@cached` | Reactive, efficient, no lifecycle needed |
| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup |
| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup |
| Event listeners | `{{on}}` modifier | Built-in, automatic cleanup |
| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility |
### Migration Strategy
If you have existing code using these hooks:
1. **Identify the purpose**: What is the hook doing?
2. **Choose the right alternative**:
- Deriving data? → Use getters/`@cached`
- DOM setup/teardown? → Use a custom modifier
- Async data loading? → Use getPromiseState from warp-drive
3. **Test thoroughly**: Ensure cleanup happens correctly
4. **Remove the legacy hook**: Delete `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}`
### Performance Benefits
Modern alternatives provide better performance:
- **Getters**: Only compute when dependencies change
- **@cached**: Memoizes expensive computations
- **Modifiers**: Scoped to specific elements, composable
- **getPromiseState**: Declarative data loading, automatic cleanup
### Common Pitfalls to Avoid
❌ **Don't use `willDestroy()` for cleanup when a modifier would work**
❌ **Don't use `@action` + `did-insert` when a getter would suffice**
❌ **Don't manually track changes when `@cached` handles it automatically**
❌ **Don't forget `registerDestructor` in custom modifiers**
### Summary
Modern Ember provides superior alternatives to legacy lifecycle hooks:
- **Derived Data**: Use getters and `@cached` for reactive computations
- **DOM Side Effects**: Use custom modifiers with `registerDestructor`
- **Async Data Loading**: Use getPromiseState from warp-drive/reactiveweb
- **Better Code**: More testable, composable, and maintainable
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.**
Reference:
- [Ember Modifiers](https://github.com/ember-modifier/ember-modifier)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
- [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)

View File

@@ -0,0 +1,53 @@
---
title: Use @cached for Expensive Getters
impact: HIGH
impactDescription: 50-90% reduction in recomputation
tags: components, performance, caching, tracked
---
## Use @cached for Expensive Getters
Use `@cached` from `@glimmer/tracking` to memoize expensive computations that depend on tracked properties. The cached value is automatically invalidated when dependencies change.
**Incorrect (recomputes on every access):**
```javascript
import Component from '@glimmer/component';
class DataTable extends Component {
get filteredAndSortedData() {
// Expensive: runs on every access, even if nothing changed
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
}
```
**Correct (cached computation):**
```javascript
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class DataTable extends Component {
@cached
get filteredAndSortedData() {
// Computed once per unique combination of dependencies
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
transformItem(item) {
// Expensive transformation
return { ...item, computed: this.expensiveCalculation(item) };
}
}
```
`@cached` memoizes the getter result and only recomputes when tracked dependencies change, providing 50-90% reduction in unnecessary work.
Reference: [@cached decorator](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/#toc_caching)

View File

@@ -0,0 +1,324 @@
---
title: Use Class Fields for Component Composition
impact: MEDIUM-HIGH
impactDescription: Better composition and initialization patterns
tags: components, class-fields, composition, initialization
---
## Use Class Fields for Component Composition
Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be **roots of state** - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.
**Incorrect (imperative initialization, scattered state):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
class DataManager extends Component {
@service store;
@service router;
// Scattered state management - hard to track relationships
@tracked currentUser = null;
@tracked isLoading = false;
@tracked error = null;
loadData = async () => {
this.isLoading = true;
try {
this.currentUser = await this.store.request({ url: '/users/me' });
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
};
<template>
<div>{{this.currentUser.name}}</div>
</template>
}
```
**Correct (class fields with proper patterns):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class DataManager extends Component {
// Service injection as class fields
@service store;
@service router;
// Tracked state as class fields - this is a "root of state"
// Most components should have very few of these
@tracked selectedFilter = 'all';
// Data loading with getPromiseState
@cached
get currentUser() {
const promise = this.store.request({
url: '/users/me',
});
return getPromiseState(promise);
}
<template>
{{#if this.currentUser.isFulfilled}}
<div>{{this.currentUser.value.name}}</div>
{{else if this.currentUser.isRejected}}
<div>Error: {{this.currentUser.error.message}}</div>
{{/if}}
</template>
}
```
**Understanding "roots of state":**
Tracked fields should represent **independent state** that your component owns - not derived data or loaded data. Examples of good tracked fields:
- User selections (selected tab, filter option)
- UI state (is modal open, is expanded)
- Form input values (not yet persisted)
In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.
**Composition through class field assignment:**
```glimmer-js
// app/components/form-container.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';
class FormContainer extends Component {
// Compose form state
@tracked formData = new TrackedObject({
firstName: '',
lastName: '',
email: '',
preferences: {
newsletter: false,
notifications: true,
},
});
// Compose validation state
@tracked errors = new TrackedObject({});
// Compose UI state
@tracked ui = new TrackedObject({
isSubmitting: false,
isDirty: false,
showErrors: false,
});
// Computed field based on composed state
get isValid() {
return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
}
get canSubmit() {
return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
}
updateField = (field, value) => {
this.formData[field] = value;
this.ui.isDirty = true;
this.validate(field, value);
};
validate(field, value) {
if (field === 'email' && !value.includes('@')) {
this.errors.email = 'Invalid email';
} else {
delete this.errors[field];
}
}
<template>
<form>
<input
value={{this.formData.firstName}}
{{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
/>
<button disabled={{not this.canSubmit}}>
Submit
</button>
</form>
</template>
}
```
**Mixin-like composition with class fields:**
```javascript
// app/utils/pagination-mixin.js
import { tracked } from '@glimmer/tracking';
export class PaginationState {
@tracked page = 1;
@tracked perPage = 20;
get offset() {
return (this.page - 1) * this.perPage;
}
nextPage = () => {
this.page++;
};
prevPage = () => {
if (this.page > 1) this.page--;
};
goToPage = (page) => {
this.page = page;
};
}
```
```glimmer-js
// app/components/paginated-list.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { PaginationState } from '../utils/pagination-mixin';
class PaginatedList extends Component {
// Compose pagination functionality
pagination = new PaginationState();
@cached
get paginatedItems() {
const start = this.pagination.offset;
const end = start + this.pagination.perPage;
return this.args.items.slice(start, end);
}
get totalPages() {
return Math.ceil(this.args.items.length / this.pagination.perPage);
}
<template>
<div class="list">
{{#each this.paginatedItems as |item|}}
<div>{{item.name}}</div>
{{/each}}
<div class="pagination">
<button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
Previous
</button>
<span>Page {{this.pagination.page}} of {{this.totalPages}}</span>
<button
{{on "click" this.pagination.nextPage}}
disabled={{eq this.pagination.page this.totalPages}}
>
Next
</button>
</div>
</div>
</template>
}
```
**Shareable state objects:**
```javascript
// app/utils/selection-state.js
import { tracked } from '@glimmer/tracking';
import { TrackedSet } from 'tracked-built-ins';
export class SelectionState {
@tracked selectedIds = new TrackedSet();
get count() {
return this.selectedIds.size;
}
get hasSelection() {
return this.selectedIds.size > 0;
}
isSelected(id) {
return this.selectedIds.has(id);
}
toggle = (id) => {
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
};
selectAll = (items) => {
items.forEach((item) => this.selectedIds.add(item.id));
};
clear = () => {
this.selectedIds.clear();
};
}
```
```glimmer-js
// app/components/selectable-list.gjs
import Component from '@glimmer/component';
import { SelectionState } from '../utils/selection-state';
class SelectableList extends Component {
// Compose selection behavior
selection = new SelectionState();
get selectedItems() {
return this.args.items.filter((item) => this.selection.isSelected(item.id));
}
<template>
<div class="toolbar">
<button {{on "click" (fn this.selection.selectAll @items)}}>
Select All
</button>
<button {{on "click" this.selection.clear}}>
Clear
</button>
<span>{{this.selection.count}} selected</span>
</div>
<ul>
{{#each @items as |item|}}
<li class={{if (this.selection.isSelected item.id) "selected"}}>
<input
type="checkbox"
checked={{this.selection.isSelected item.id}}
{{on "change" (fn this.selection.toggle item.id)}}
/>
{{item.name}}
</li>
{{/each}}
</ul>
{{#if this.selection.hasSelection}}
<div class="actions">
<button>Delete {{this.selection.count}} items</button>
</div>
{{/if}}
</template>
}
```
Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.
Reference: [JavaScript Class Fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields)

View File

@@ -0,0 +1,241 @@
---
title: Use Component Composition Patterns
impact: HIGH
impactDescription: Better code reuse and maintainability
tags: components, composition, yield, blocks, contextual-components
---
## Use Component Composition Patterns
Use component composition with yield blocks, named blocks, and contextual components for flexible, reusable UI patterns.
**Named blocks** are for invocation consistency in design systems where you **don't want the caller to have full markup control**. They provide structured extension points while maintaining design system constraints - the same concept as named slots in other frameworks.
**Incorrect (monolithic component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
{{#if @showActions}}
<div class="actions">
<button {{on "click" @onEdit}}>Edit</button>
<button {{on "click" @onDelete}}>Delete</button>
</div>
{{/if}}
{{#if @showStats}}
<div class="stats">
<span>Posts: {{@user.postCount}}</span>
<span>Followers: {{@user.followers}}</span>
</div>
{{/if}}
</div>
</template>
}
```
**Correct (composable with named blocks):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card" ...attributes>
{{#if (has-block "header")}}
{{yield to="header"}}
{{else}}
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
</div>
{{/if}}
{{yield @user to="default"}}
{{#if (has-block "actions")}}
<div class="actions">
{{yield @user to="actions"}}
</div>
{{/if}}
{{#if (has-block "footer")}}
<div class="footer">
{{yield @user to="footer"}}
</div>
{{/if}}
</div>
</template>
}
```
**Usage with flexible composition:**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
{{#each @users as |user|}}
<UserCard @user={{user}}>
<:header>
<div class="custom-header">
<span class="badge">{{user.role}}</span>
<h3>{{user.name}}</h3>
</div>
</:header>
<:default as |u|>
<p class="bio">{{u.bio}}</p>
<p class="email">{{u.email}}</p>
</:default>
<:actions as |u|>
<button {{on "click" (fn @onEdit u)}}>Edit</button>
<button {{on "click" (fn @onDelete u)}}>Delete</button>
</:actions>
<:footer as |u|>
<div class="stats">
Posts:
{{u.postCount}}
| Followers:
{{u.followers}}
</div>
</:footer>
</UserCard>
{{/each}}
</template>
```
**Contextual components pattern:**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { hash } from '@ember/helper';
class HeaderCell extends Component {
<template>
<th class="sortable" {{on "click" @onSort}}>
{{yield}}
{{#if @sorted}}
<span class="sort-icon">{{if @ascending "↑" "↓"}}</span>
{{/if}}
</th>
</template>
}
class Row extends Component {
<template>
<tr class={{if @selected "selected"}}>
{{yield}}
</tr>
</template>
}
class Cell extends Component {
<template>
<td>{{yield}}</td>
</template>
}
class DataTable extends Component {
<template>
<table class="data-table">
{{yield (hash Header=HeaderCell Row=Row Cell=Cell)}}
</table>
</template>
}
```
**Using contextual components:**
```glimmer-js
// app/components/users-table.gjs
import DataTable from './data-table';
<template>
<DataTable as |Table|>
<thead>
<tr>
<Table.Header @onSort={{fn @onSort "name"}}>Name</Table.Header>
<Table.Header @onSort={{fn @onSort "email"}}>Email</Table.Header>
<Table.Header @onSort={{fn @onSort "role"}}>Role</Table.Header>
</tr>
</thead>
<tbody>
{{#each @users as |user|}}
<Table.Row @selected={{eq @selectedId user.id}}>
<Table.Cell>{{user.name}}</Table.Cell>
<Table.Cell>{{user.email}}</Table.Cell>
<Table.Cell>{{user.role}}</Table.Cell>
</Table.Row>
{{/each}}
</tbody>
</DataTable>
</template>
```
**Renderless component pattern:**
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { hash } from '@ember/helper';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggle() {
this.isOpen = !this.isOpen;
}
@action
close() {
this.isOpen = false;
}
<template>{{yield (hash isOpen=this.isOpen toggle=this.toggle close=this.close)}}</template>
}
```
```glimmer-js
// Usage
import Dropdown from './dropdown';
<template>
<Dropdown as |dd|>
<button {{on "click" dd.toggle}}>
Menu
{{if dd.isOpen "▲" "▼"}}
</button>
{{#if dd.isOpen}}
<ul class="dropdown-menu">
<li><a href="#" {{on "click" dd.close}}>Profile</a></li>
<li><a href="#" {{on "click" dd.close}}>Settings</a></li>
<li><a href="#" {{on "click" dd.close}}>Logout</a></li>
</ul>
{{/if}}
</Dropdown>
</template>
```
Component composition provides flexibility, reusability, and clean separation of concerns while maintaining type safety and clarity.
Reference: [Ember Components - Block Parameters](https://guides.emberjs.com/release/components/block-content/)

View File

@@ -0,0 +1,328 @@
---
title: Use Native Forms with Platform Validation
impact: HIGH
impactDescription: Reduces JavaScript form complexity and improves built-in a11y
tags: components, forms, validation, accessibility, platform
---
## Use Native Forms with Platform Validation
Rely on native `<form>` elements and the browser's Constraint Validation API instead of reinventing form handling with JavaScript. The platform is really good at forms.
## Problem
Over-engineering forms with JavaScript when native browser features provide validation, accessibility, and UX patterns for free.
**Incorrect (Too much JavaScript):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class SignupForm extends Component {
@tracked email = '';
@tracked emailError = '';
validateEmail = () => {
// ❌ Reinventing email validation
if (!this.email.includes('@')) {
this.emailError = 'Invalid email';
}
};
handleSubmit = (event) => {
event.preventDefault();
if (this.emailError) return;
// Submit logic
};
<template>
<div>
<input
type="text"
value={{this.email}}
{{on "input" this.updateEmail}}
{{on "blur" this.validateEmail}}
/>
{{#if this.emailError}}
<span class="error">{{this.emailError}}</span>
{{/if}}
<button type="button" {{on "click" this.handleSubmit}}>Submit</button>
</div>
</template>
}
```
## Solution: Let the Platform Do the Work
Use native `<form>` with proper input types and browser validation:
**Correct (Native form with platform validation):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class SignupForm extends Component {
@tracked validationErrors = null;
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
// ✅ Use native checkValidity()
if (!form.checkValidity()) {
// Show native validation messages
form.reportValidity();
return;
}
// ✅ Use FormData API - no tracked state needed!
const formData = new FormData(form);
const data = Object.fromEntries(formData);
this.args.onSubmit(data);
};
<template>
<form {{on "submit" this.handleSubmit}}>
{{! ✅ Browser handles validation automatically }}
<input type="email" name="email" required placeholder="email@example.com" />
<input
type="password"
name="password"
required
minlength="8"
placeholder="Min 8 characters"
/>
<button type="submit">Sign Up</button>
</form>
</template>
}
```
**Performance: -15KB** (no validation libraries needed)
**Accessibility: +100%** (native form semantics and error announcements)
**Code: -50%** (let the platform handle it)
## Custom Validation Messages with Constraint Validation API
Access and display native validation state in your component:
```glimmer-js
// app/components/validated-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class ValidatedForm extends Component {
@tracked errors = new Map();
handleInput = (event) => {
const input = event.target;
// ✅ Access Constraint Validation API
if (!input.validity.valid) {
this.errors.set(input.name, input.validationMessage);
} else {
this.errors.delete(input.name);
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
// Trigger native validation UI
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<div>
<label for="email">Email</label>
<input id="email" type="email" name="email" required {{on "input" this.handleInput}} />
{{#if (this.errors.get "email")}}
<span class="error" role="alert">
{{this.errors.get "email"}}
</span>
{{/if}}
</div>
<div>
<label for="age">Age</label>
<input
id="age"
type="number"
name="age"
min="18"
max="120"
required
{{on "input" this.handleInput}}
/>
{{#if (this.errors.get "age")}}
<span class="error" role="alert">
{{this.errors.get "age"}}
</span>
{{/if}}
</div>
<button type="submit">Submit</button>
</form>
</template>
}
```
## Constraint Validation API Properties
The browser provides rich validation state via `input.validity`:
```javascript
handleInput = (event) => {
const input = event.target;
const validity = input.validity;
// Check specific validation states:
if (validity.valueMissing) {
// required field is empty
}
if (validity.typeMismatch) {
// type="email" but value isn't email format
}
if (validity.tooShort || validity.tooLong) {
// minlength/maxlength violated
}
if (validity.rangeUnderflow || validity.rangeOverflow) {
// min/max violated
}
if (validity.patternMismatch) {
// pattern attribute not matched
}
// Or use the aggregated validationMessage:
if (!validity.valid) {
this.showError(input.name, input.validationMessage);
}
};
```
## Custom Validation with setCustomValidity
For business logic validation beyond HTML5 constraints:
```glimmer-js
// app/components/password-match-form.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class PasswordMatchForm extends Component {
validatePasswordMatch = (event) => {
const form = event.target.form;
const password = form.querySelector('[name="password"]');
const confirm = form.querySelector('[name="confirm"]');
// ✅ Use setCustomValidity for custom validation
if (password.value !== confirm.value) {
confirm.setCustomValidity('Passwords must match');
} else {
confirm.setCustomValidity(''); // Clear custom error
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<input type="password" name="password" required minlength="8" placeholder="Password" />
<input
type="password"
name="confirm"
required
placeholder="Confirm password"
{{on "input" this.validatePasswordMatch}}
/>
<button type="submit">Create Account</button>
</form>
</template>
}
```
## When You Need Controlled State
Use controlled patterns when you need real-time interactivity that isn't form submission:
```glimmer-js
// app/components/live-search.gjs - Controlled state needed for instant search
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class LiveSearch extends Component {
@tracked query = '';
updateQuery = (event) => {
this.query = event.target.value;
// Instant search as user types
this.args.onSearch?.(this.query);
};
<template>
{{! Controlled state justified - need instant feedback }}
<input
type="search"
value={{this.query}}
{{on "input" this.updateQuery}}
placeholder="Search..."
/>
{{#if this.query}}
<p>Searching for: {{this.query}}</p>
{{/if}}
</template>
}
```
**Use controlled state when you need:**
- Real-time validation display as user types
- Character counters
- Live search/filtering
- Multi-step forms where state drives UI
- Form state that affects other components
**Use native forms when:**
- Simple submit-and-validate workflows
- Standard HTML5 validation is sufficient
- You want browser-native UX and accessibility
- Simpler code and less JavaScript is better
## References
- [MDN: Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/API/Constraint_validation)
- [MDN: FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
- [MDN: Form Validation](https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation)
- [Ember Guides: Event Handling](https://guides.emberjs.com/release/components/component-state-and-actions/)

View File

@@ -0,0 +1,216 @@
---
title: Component File Naming and Export Conventions
impact: HIGH
impactDescription: Enforces consistent component structure and predictable imports
tags: components, naming, file-conventions, gjs, strict-mode
---
## Component File Naming and Export Conventions
### Rule
Follow modern Ember component file conventions: use `.gjs`/`.gts` files with `<template>` tags (never `.hbs` files), use kebab-case filenames, match class names to file names (in PascalCase), do not use the `Component` suffix in class names, and avoid `export default` in .gjs/.gts component files.
This export guidance applies to `.gjs`/`.gts` component files only. If your app still uses `.hbs`, keep default exports for resolver-facing invokables used there (or use a named export plus default alias in hybrid codebases).
**Incorrect:**
```handlebars
{{! app/components/user-card.hbs - WRONG: Using .hbs file }}
<div class='user-card'>
{{@name}}
</div>
```
```glimmer-js
// app/components/user-card.js - WRONG: Separate .js and .hbs files
import Component from '@glimmer/component';
export class UserCard extends Component {
// Logic here
}
```
```glimmer-js
// app/components/user-card.gjs - WRONG: Component suffix
import Component from '@glimmer/component';
export class UserCardComponent extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/UserProfile.gjs - WRONG: PascalCase filename
import Component from '@glimmer/component';
export class UserProfile extends Component {
<template>
<div class="profile">
{{@name}}
</div>
</template>
}
```
**Correct:**
```glimmer-js
// app/components/user-card.gjs - CORRECT: kebab-case filename, no Component suffix, no default export
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/user-profile.gjs - CORRECT: All conventions followed
import Component from '@glimmer/component';
import { service } from '@ember/service';
export class UserProfile extends Component {
@service session;
<template>
<div class="profile">
<h1>{{@name}}</h1>
{{#if this.session.isAuthenticated}}
<button>Edit Profile</button>
{{/if}}
</div>
</template>
}
```
## Why
**Never use .hbs files:**
- `.gjs`/`.gts` files with `<template>` tags are the modern standard
- Co-located templates and logic in a single file improve maintainability
- Better tooling support (type checking, imports, refactoring)
- Enables strict mode and proper scope
- Avoid split between `.js` and `.hbs` files which makes components harder to understand
**Filename conventions:**
- Kebab-case filenames (`user-card.gjs`, not `UserCard.gjs`) follow web component standards and Ember conventions
- Predictable: component name maps directly to filename (UserCard → user-card.gjs)
- Avoids filesystem case-sensitivity issues across platforms
**Class naming:**
- No "Component" suffix - it's redundant (extends Component already declares the type)
- PascalCase class name matches the capitalized component invocation: `<UserCard />`
- Cleaner code: `UserCard` vs `UserCardComponent`
**No default export:**
- Modern .gjs/.gts files don't need `export default`
- The template compiler automatically exports the component
- Simpler syntax, less boilerplate
- Consistent with strict-mode semantics
## Naming Pattern Reference
| Filename | Class Name | Template Invocation |
| --------------------- | ---------------------- | -------------------- |
| `user-card.gjs` | `class UserCard` | `<UserCard />` |
| `loading-spinner.gjs` | `class LoadingSpinner` | `<LoadingSpinner />` |
| `nav-bar.gjs` | `class NavBar` | `<NavBar />` |
| `todo-list.gjs` | `class TodoList` | `<TodoList />` |
| `search-input.gjs` | `class SearchInput` | `<SearchInput />` |
**Conversion rule:**
- Filename: all lowercase, words separated by hyphens
- Class: PascalCase, same words, no hyphens
- `user-card.gjs` → `class UserCard`
## Special Cases
**Template-only components:**
```glimmer-js
// app/components/simple-card.gjs - Template-only, no class needed
<template>
<div class="card">
{{yield}}
</div>
</template>
```
**Components in subdirectories:**
```glimmer-js
// app/components/ui/button.gjs
import Component from '@glimmer/component';
export class Button extends Component {
<template>
<button type="button">
{{yield}}
</button>
</template>
}
// Usage: <Ui::Button />
```
**Nested namespaces:**
```glimmer-js
// app/components/admin/user/profile-card.gjs
import Component from '@glimmer/component';
export class ProfileCard extends Component {
<template>
<div class="admin-profile">
{{@user.name}}
</div>
</template>
}
// Usage: <Admin::User::ProfileCard />
```
## Impact
**Positive:**
- ⚡️ Cleaner, more maintainable code
- 🎯 Predictable mapping between files and classes
- 🌐 Follows web standards (kebab-case)
- 📦 Smaller bundle size (less export overhead)
- 🚀 Better alignment with modern Ember/Glimmer
**Negative:**
- None - this is the modern standard
## Metrics
- **Code clarity**: +30% (shorter, clearer names)
- **Bundle size**: -5-10 bytes per component (no export overhead)
- **Developer experience**: Improved (predictable naming)
## References
- [Ember Components Guide](https://guides.emberjs.com/release/components/)
- [Glimmer Components](https://github.com/glimmerjs/glimmer.js)
- [Template Tag Format RFC](https://github.com/emberjs/rfcs/pull/779)
- [Strict Mode Semantics](https://github.com/emberjs/rfcs/blob/master/text/0496-handlebars-strict-mode.md)
## Related Rules
- component-use-glimmer.md - Modern Glimmer component patterns
- component-strict-mode.md - Template-only components and strict mode
- route-templates.md - Route file naming conventions

View File

@@ -0,0 +1,219 @@
---
title: Prevent Memory Leaks in Components
impact: HIGH
impactDescription: Avoid memory leaks and resource exhaustion
tags: memory, cleanup, lifecycle, performance
---
## Prevent Memory Leaks in Components
Properly clean up event listeners, timers, and subscriptions to prevent memory leaks.
**Incorrect (no cleanup):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
// Memory leak: interval never cleared
setInterval(() => {
this.time = new Date();
}, 1000);
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Correct (proper cleanup with registerDestructor):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
const intervalId = setInterval(() => {
this.time = new Date();
}, 1000);
// Proper cleanup
registerDestructor(this, () => {
clearInterval(intervalId);
});
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Event listener cleanup:**
```glimmer-js
// app/components/window-size.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class WindowSize extends Component {
@tracked width = window.innerWidth;
@tracked height = window.innerHeight;
constructor() {
super(...arguments);
const handleResize = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
};
window.addEventListener('resize', handleResize);
registerDestructor(this, () => {
window.removeEventListener('resize', handleResize);
});
}
<template>
<div>Window: {{this.width}} x {{this.height}}</div>
</template>
}
```
**Using modifiers for automatic cleanup:**
```javascript
// app/modifiers/window-listener.js
import { modifier } from 'ember-modifier';
export default modifier((element, [eventName, handler]) => {
window.addEventListener(eventName, handler);
// Automatic cleanup when element is removed
return () => {
window.removeEventListener(eventName, handler);
};
});
```
```glimmer-js
// app/components/resize-aware.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import windowListener from '../modifiers/window-listener';
class ResizeAware extends Component {
@tracked size = { width: 0, height: 0 };
handleResize = () => {
this.size = {
width: window.innerWidth,
height: window.innerHeight,
};
};
<template>
<div {{windowListener "resize" this.handleResize}}>
{{this.size.width}}
x
{{this.size.height}}
</div>
</template>
}
```
**Abort controller for fetch requests:**
```glimmer-js
// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class DataLoader extends Component {
@tracked data = null;
abortController = new AbortController();
constructor() {
super(...arguments);
this.loadData();
registerDestructor(this, () => {
this.abortController.abort();
});
}
async loadData() {
try {
const response = await fetch('/api/data', {
signal: this.abortController.signal,
});
this.data = await response.json();
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load data:', error);
}
}
}
<template>
{{#if this.data}}
<div>{{this.data.content}}</div>
{{/if}}
</template>
}
```
**Using ember-resources for automatic cleanup:**
```glimmer-js
// app/components/websocket-data.gjs
import Component from '@glimmer/component';
import { resource } from 'ember-resources';
class WebsocketData extends Component {
messages = resource(({ on }) => {
const messages = [];
const ws = new WebSocket('wss://example.com/socket');
ws.onmessage = (event) => {
messages.push(event.data);
};
// Automatic cleanup
on.cleanup(() => {
ws.close();
});
return messages;
});
<template>
{{#each this.messages.value as |message|}}
<div>{{message}}</div>
{{/each}}
</template>
}
```
Always clean up timers, event listeners, subscriptions, and pending requests to prevent memory leaks and performance degradation.
Reference: [Ember Destroyable](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable)

View File

@@ -0,0 +1,57 @@
---
title: Avoid Unnecessary Tracking
impact: HIGH
impactDescription: 20-40% fewer invalidations
tags: components, tracked, performance, reactivity
---
## Avoid Unnecessary Tracking
Only mark properties as `@tracked` if they need to trigger re-renders when changed. Overusing `@tracked` causes unnecessary invalidations and re-renders.
**Incorrect (everything tracked):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Form extends Component {
@tracked firstName = ''; // Used in template ✓
@tracked lastName = ''; // Used in template ✓
@tracked _formId = Date.now(); // Internal, never rendered ✗
@tracked _validationCache = new Map(); // Internal state ✗
@action
validate() {
this._validationCache.set('firstName', this.firstName.length > 0);
// Unnecessary re-render triggered
}
}
```
**Correct (selective tracking):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Form extends Component {
@tracked firstName = ''; // Rendered in template
@tracked lastName = ''; // Rendered in template
@tracked isValid = false; // Rendered status
_formId = Date.now(); // Not tracked - internal only
_validationCache = new Map(); // Not tracked - internal state
@action
validate() {
this._validationCache.set('firstName', this.firstName.length > 0);
this.isValid = this._validationCache.get('firstName');
// Only re-renders when isValid changes
}
}
```
Only track properties that directly affect the template or other tracked getters to minimize unnecessary re-renders.

View File

@@ -0,0 +1,132 @@
---
title: Use {{on}} Modifier for Event Handling
impact: MEDIUM
impactDescription: Better memory management and clarity
tags: events, modifiers, on, performance
---
## Use {{on}} Modifier for Event Handling
Use the `{{on}}` modifier for event handling instead of traditional action handlers for better memory management and clearer code.
**Incorrect (traditional action attribute):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Button extends Component {
@action
handleClick() {
this.args.onClick?.();
}
<template>
<button onclick={{this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**Correct (using {{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class Button extends Component {
handleClick = () => {
this.args.onClick?.();
};
<template>
<button {{on "click" this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**With event options:**
```glimmer-js
// app/components/scroll-tracker.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class ScrollTracker extends Component {
handleScroll = (event) => {
console.log('Scroll position:', event.target.scrollTop);
};
<template>
<div class="scrollable" {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Multiple event handlers:**
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class InputField extends Component {
@tracked isFocused = false;
handleFocus = () => {
this.isFocused = true;
};
handleBlur = () => {
this.isFocused = false;
};
handleInput = (event) => {
this.args.onInput?.(event.target.value);
};
<template>
<input
type="text"
class={{if this.isFocused "focused"}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
value={{@value}}
/>
</template>
}
```
**Using fn helper for arguments:**
```glimmer-js
// app/components/item-list.gjs
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
<template>
<ul>
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn @onDelete item.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
```
The `{{on}}` modifier properly cleans up event listeners, supports event options (passive, capture, once), and makes event handling more explicit.
Reference: [Ember Modifiers - on](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers)

View File

@@ -0,0 +1,292 @@
---
title: Build Reactive Chains with Dependent Getters
impact: HIGH
impactDescription: Clear data flow and automatic reactivity
tags: reactivity, getters, tracked, derived-state, composition
---
## Build Reactive Chains with Dependent Getters
Create reactive chains where getters depend on other getters or tracked properties for clear, maintainable data derivation.
**Incorrect (imperative updates):**
```glimmer-js
// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class ShoppingCart extends Component {
@tracked items = [];
@tracked subtotal = 0;
@tracked tax = 0;
@tracked shipping = 0;
@tracked total = 0;
@action
addItem(item) {
this.items = [...this.items, item];
this.recalculate();
}
@action
removeItem(index) {
this.items = this.items.filter((_, i) => i !== index);
this.recalculate();
}
recalculate() {
this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
this.tax = this.subtotal * 0.08;
this.shipping = this.subtotal > 50 ? 0 : 5.99;
this.total = this.subtotal + this.tax + this.shipping;
}
<template>
<div class="cart">
<div>Subtotal: ${{this.subtotal}}</div>
<div>Tax: ${{this.tax}}</div>
<div>Shipping: ${{this.shipping}}</div>
<div>Total: ${{this.total}}</div>
</div>
</template>
}
```
**Correct (reactive getter chains):**
```glimmer-js
// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
class ShoppingCart extends Component {
@tracked items = new TrackedArray([]);
// Base calculation
get subtotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
// Depends on subtotal
get tax() {
return this.subtotal * 0.08;
}
// Depends on subtotal
get shipping() {
return this.subtotal > 50 ? 0 : 5.99;
}
// Depends on subtotal, tax, and shipping
get total() {
return this.subtotal + this.tax + this.shipping;
}
// Derived from total
get formattedTotal() {
return `$${this.total.toFixed(2)}`;
}
// Multiple dependencies
get discount() {
if (this.items.length >= 5) return this.subtotal * 0.1;
if (this.subtotal > 100) return this.subtotal * 0.05;
return 0;
}
// Depends on total and discount
get finalTotal() {
return this.total - this.discount;
}
@action
addItem(item) {
this.items.push(item);
// All getters automatically update!
}
@action
removeItem(index) {
this.items.splice(index, 1);
// All getters automatically update!
}
<template>
<div class="cart">
<div>Items: {{this.items.length}}</div>
<div>Subtotal: ${{this.subtotal.toFixed 2}}</div>
<div>Tax: ${{this.tax.toFixed 2}}</div>
<div>Shipping: ${{this.shipping.toFixed 2}}</div>
{{#if this.discount}}
<div class="discount">Discount: -${{this.discount.toFixed 2}}</div>
{{/if}}
<div class="total">Total: {{this.formattedTotal}}</div>
</div>
</template>
}
```
**Complex reactive chains with @cached:**
```glimmer-js
// app/components/data-analysis.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class DataAnalysis extends Component {
// Base data
get rawData() {
return this.args.data || [];
}
// Level 1: Filter
@cached
get validData() {
return this.rawData.filter((item) => item.value != null);
}
// Level 2: Transform (depends on validData)
@cached
get normalizedData() {
const max = Math.max(...this.validData.map((d) => d.value));
return this.validData.map((item) => ({
...item,
normalized: item.value / max,
}));
}
// Level 2: Statistics (depends on validData)
@cached
get statistics() {
const values = this.validData.map((d) => d.value);
const sum = values.reduce((a, b) => a + b, 0);
const mean = sum / values.length;
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length;
return {
count: values.length,
sum,
mean,
stdDev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
};
}
// Level 3: Depends on normalizedData and statistics
@cached
get outliers() {
const threshold = this.statistics.mean + 2 * this.statistics.stdDev;
return this.normalizedData.filter((item) => item.value > threshold);
}
// Level 3: Depends on statistics
get qualityScore() {
const validRatio = this.validData.length / this.rawData.length;
const outlierRatio = this.outliers.length / this.validData.length;
return validRatio * 0.7 + (1 - outlierRatio) * 0.3;
}
<template>
<div class="analysis">
<h3>Data Quality: {{this.qualityScore.toFixed 2}}</h3>
<div>Valid: {{this.validData.length}} / {{this.rawData.length}}</div>
<div>Mean: {{this.statistics.mean.toFixed 2}}</div>
<div>Std Dev: {{this.statistics.stdDev.toFixed 2}}</div>
<div>Outliers: {{this.outliers.length}}</div>
</div>
</template>
}
```
**Combining multiple tracked sources:**
```glimmer-js
// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
class FilteredList extends Component {
@tracked searchTerm = '';
@tracked selectedCategory = 'all';
@tracked sortDirection = 'asc';
// Depends on args.items and searchTerm
@cached
get searchFiltered() {
if (!this.searchTerm) return this.args.items;
const term = this.searchTerm.toLowerCase();
return this.args.items.filter(
(item) =>
item.name.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
);
}
// Depends on searchFiltered and selectedCategory
@cached
get categoryFiltered() {
if (this.selectedCategory === 'all') return this.searchFiltered;
return this.searchFiltered.filter((item) => item.category === this.selectedCategory);
}
// Depends on categoryFiltered and sortDirection
@cached
get sorted() {
const items = [...this.categoryFiltered];
const direction = this.sortDirection === 'asc' ? 1 : -1;
return items.sort((a, b) => direction * a.name.localeCompare(b.name));
}
// Final result
get items() {
return this.sorted;
}
// Metadata derived from chain
get resultsCount() {
return this.items.length;
}
get hasFilters() {
return this.searchTerm || this.selectedCategory !== 'all';
}
<template>
<div class="filtered-list">
<input
type="search"
value={{this.searchTerm}}
{{on "input" (pick "target.value" (set this "searchTerm"))}}
/>
<select
value={{this.selectedCategory}}
{{on "change" (pick "target.value" (set this "selectedCategory"))}}
>
<option value="all">All Categories</option>
{{#each @categories as |cat|}}
<option value={{cat}}>{{cat}}</option>
{{/each}}
</select>
<p>Showing {{this.resultsCount}} results</p>
{{#each this.items as |item|}}
<div>{{item.name}}</div>
{{/each}}
</div>
</template>
}
```
Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached.
Reference: [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)

View File

@@ -0,0 +1,86 @@
---
title: Use Strict Mode and Template-Only Components
impact: HIGH
impactDescription: Better type safety and simpler components
tags: strict-mode, template-only, components, gjs
---
## Use Strict Mode and Template-Only Components
Use strict mode and template-only components for simpler, safer code with better tooling support.
**Incorrect (JavaScript component for simple templates):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (template-only component):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
```
**With TypeScript for better type safety:**
```glimmer-ts
// app/components/user-card.gts
import type { TOC } from '@ember/component/template-only';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
};
};
}
const UserCard: TOC<UserCardSignature> = <template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>;
export default UserCard;
```
**Enable strict mode in your app:**
```javascript
// ember-cli-build.js
'use strict';
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
'ember-cli-babel': {
enableTypeScriptTransform: true,
},
});
return app.toTree();
};
```
Template-only components are lighter, more performant, and easier to understand. Strict mode provides better error messages and prevents common mistakes.
Reference: [Ember Strict Mode](https://guides.emberjs.com/release/upgrading/current-edition/templates/)

View File

@@ -0,0 +1,68 @@
---
title: Use Tracked Toolbox for Complex State
impact: HIGH
impactDescription: Cleaner state management
tags: components, tracked, state-management, performance
---
## Use Tracked Toolbox for Complex State
For complex state patterns like maps, sets, and arrays that need fine-grained reactivity, use tracked-toolbox utilities instead of marking entire structures as @tracked.
**Incorrect (tracking entire structures):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class TodoList extends Component {
@tracked items = []; // Entire array replaced on every change
addItem = (item) => {
// Creates new array, invalidates all consumers
this.items = [...this.items, item];
};
removeItem = (index) => {
// Creates new array again
this.items = this.items.filter((_, i) => i !== index);
};
}
```
**Correct (using tracked-toolbox):**
```javascript
import Component from '@glimmer/component';
import { TrackedArray } from 'tracked-built-ins';
class TodoList extends Component {
items = new TrackedArray([]);
// Use arrow functions for methods used in templates (no @action needed)
addItem = (item) => {
// Efficiently adds to tracked array
this.items.push(item);
};
removeItem = (index) => {
// Efficiently removes from tracked array
this.items.splice(index, 1);
};
}
```
**Also useful for Maps and Sets:**
```javascript
import { TrackedMap, TrackedSet } from 'tracked-built-ins';
class Cache extends Component {
cache = new TrackedMap(); // Fine-grained reactivity per key
selected = new TrackedSet(); // Fine-grained reactivity per item
}
```
tracked-built-ins provides fine-grained reactivity and better performance than replacing entire structures.
Reference: [tracked-built-ins](https://github.com/tracked-tools/tracked-built-ins)

View File

@@ -0,0 +1,56 @@
---
title: Use Glimmer Components Over Classic Components
impact: HIGH
impactDescription: 30-50% faster rendering
tags: components, glimmer, performance, reactivity
---
## Use Glimmer Components Over Classic Components
Glimmer components are lighter, faster, and have a simpler lifecycle than classic Ember components. They don't have two-way bindings or element lifecycle hooks, making them more predictable and performant.
**Incorrect (classic component):**
```javascript
// app/components/user-card.js
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: 'div',
classNames: ['user-card'],
fullName: computed('user.{firstName,lastName}', function () {
return `${this.user.firstName} ${this.user.lastName}`;
}),
didInsertElement() {
this._super(...arguments);
// Complex lifecycle management
},
});
```
**Correct (Glimmer component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
get fullName() {
return `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div class="user-card">
<h3>{{this.fullName}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
Glimmer components are 30-50% faster, have cleaner APIs, and integrate better with tracked properties.
Reference: [Glimmer Components](https://guides.emberjs.com/release/components/component-state-and-actions/)

View File

@@ -0,0 +1,168 @@
---
title: Prefer Named Exports, Fallback to Default for Implicit Template Lookup
impact: LOW
impactDescription: Clear export contracts across .hbs and template-tag codebases
tags: exports, hbs, gjs, interop, code-organization
---
## Prefer Named Exports, Fallback to Default for Implicit Template Lookup
Use named exports for shared modules imported directly in JS/TS (utilities, constants, pure functions). If a module should be invokable from `.hbs` templates via implicit lookup, provide a default export. In hybrid `.gjs`/`.hbs` projects, a practical pattern is a named export plus a default export alias.
**Incorrect (default export in a shared utility module):**
```javascript
// app/utils/format-date.js
export default function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (named export in a shared utility module):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (hybrid `.gjs`/`.hbs` named export + default alias):**
```javascript
// app/helpers/format-date.js
import { helper } from '@ember/component/helper';
export const formatDate = helper(([value]) => {
return new Date(value).toLocaleDateString();
});
export default formatDate;
```
## Where Named Exports Are Preferred
Use named exports when the module is imported directly by other modules and is not resolved via implicit template lookup.
**Example (utility module with multiple named exports):**
```javascript
// app/utils/validators.js
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export function isPhoneNumber(value) {
return /^\d{3}-\d{3}-\d{4}$/.test(value);
}
```
Benefits:
1. Explicit import contracts
2. Better refactor safety (symbol rename tracking)
3. Better tree-shaking for utility modules
4. Easier multi-export module organization
## Where Default Exports Are Required
Use default exports for modules consumed through resolver/template lookup.
If your project uses `.hbs`, invokables that should be accessible from templates should provide `export default`.
In hybrid `.gjs`/`.hbs` codebases, use named exports plus a default export alias where you want both explicit imports and template compatibility.
**Service:**
```javascript
// app/services/auth.js
import Service from '@ember/service';
export default class AuthService extends Service {
// ...
}
```
**Route:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
model() {
return this.store.findAll('dashboard-item');
}
}
```
**Modifier (when invoked from `.hbs`):**
```javascript
// app/modifiers/focus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
**Template (`.gjs`):**
```glimmer-js
// app/templates/dashboard.gjs
<template>
<h1>Dashboard</h1>
</template>
```
**Template (`.gts`):**
```glimmer-ts
// app/templates/dashboard.gts
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
model: unknown;
};
}
export default <template>
<h1>Dashboard</h1>
</template> satisfies TOC<Signature>;
```
Template-tag files must resolve via a module default export in convention-based and `import.meta.glob` flows.
For `app/templates/*.gjs`, the default export is implicit after compilation.
## Strict Resolver Nuance
With `ember-strict-application-resolver`, you can register explicit module values in `App.modules`:
**Strict resolver explicit modules registration:**
```ts
modules = {
'./services/manual': { default: ManualService },
'./services/manual-shorthand': ManualService,
};
```
In that explicit shorthand case, a direct value works without a default-exported module object.
This is an explicit registration escape hatch and does not replace default-export requirements for `.hbs`-invokable modules.
## Rule of Thumb
1. If a module should be invokable from `.hbs`, provide a default export.
2. In hybrid `.gjs`/`.hbs` projects, use named export + default alias for resolver-facing modules.
3. Strict resolver explicit `modules` entries may use direct shorthand values where appropriate.
4. Plain shared modules (`app/utils`, shared constants, reusable pure functions): prefer named exports.
5. Template-tag components (`.gjs`/`.gts`): follow the component file-conventions rule and use named class exports.
## References
- [ES Modules Best Practices](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
- [ember-strict-application-resolver](https://github.com/ember-cli/ember-strict-application-resolver)
- [ember-resolver](https://github.com/ember-cli/ember-resolver)

View File

@@ -0,0 +1,362 @@
---
title: Use Helper Libraries Effectively
impact: MEDIUM
impactDescription: Reduces custom helper maintenance and keeps templates concise
tags: templates, helpers, ember-truth-helpers, ember-composable-helpers
---
## Use Helper Libraries Effectively
Leverage community helper libraries to write cleaner templates and avoid creating unnecessary custom helpers for common operations.
## Problem
Reinventing common functionality with custom helpers adds maintenance burden and bundle size when well-maintained helper libraries already provide the needed functionality.
**Incorrect:**
```glimmer-js
// app/utils/is-equal.js - Unnecessary custom helper
export function isEqual(a, b) {
return a === b;
}
// app/components/user-badge.gjs
import { isEqual } from '../utils/is-equal';
class UserBadge extends Component {
<template>
{{#if (isEqual @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Solution
**Note:** These helpers will be built into Ember 7 core, but currently require installing the respective addon packages.
**Installation:**
```bash
npm install ember-truth-helpers ember-composable-helpers
```
Use helper libraries like `ember-truth-helpers` and `ember-composable-helpers`:
**Correct:**
```glimmer-js
// app/components/user-badge.gjs
import Component from '@glimmer/component';
import { eq } from 'ember-truth-helpers';
class UserBadge extends Component {
<template>
{{! eq helper from ember-truth-helpers }}
{{#if (eq @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Comparison Helpers (ember-truth-helpers)
**Installation:** `npm install ember-truth-helpers`
```glimmer-js
// app/components/comparison-examples.gjs
import Component from '@glimmer/component';
import { eq, not, and, or, lt, lte, gt, gte } from 'ember-truth-helpers';
class ComparisonExamples extends Component {
<template>
{{! Equality }}
{{#if (eq @status "active")}}Active{{/if}}
{{! Negation }}
{{#if (not @isDeleted)}}Visible{{/if}}
{{! Logical AND }}
{{#if (and @isPremium @hasAccess)}}Premium Content{{/if}}
{{! Logical OR }}
{{#if (or @isAdmin @isModerator)}}Moderation Tools{{/if}}
{{! Comparisons }}
{{#if (gt @score 100)}}High Score!{{/if}}
{{#if (lte @attempts 3)}}Try again{{/if}}
</template>
}
```
## Array and Object Helpers (ember-composable-helpers)
**Installation:** `npm install ember-composable-helpers`
```glimmer-js
// app/components/collection-helpers.gjs
import Component from '@glimmer/component';
import { array, hash } from 'ember-composable-helpers/helpers';
import { get } from 'ember-composable-helpers/helpers';
class CollectionHelpers extends Component {
<template>
{{! Create array inline }}
{{#each (array "apple" "banana" "cherry") as |fruit|}}
<li>{{fruit}}</li>
{{/each}}
{{! Create object inline }}
{{#let (hash name="John" age=30 active=true) as |user|}}
<p>{{user.name}} is {{user.age}} years old</p>
{{/let}}
{{! Dynamic property access }}
<p>{{get @user @propertyName}}</p>
</template>
}
```
## String Helpers
```glimmer-js
// app/components/string-helpers.gjs
import Component from '@glimmer/component';
import { concat } from '@ember/helper'; // Built-in to Ember
class StringHelpers extends Component {
<template>
{{! Concatenate strings }}
<p class={{concat "user-" @user.id "-card"}}>
{{concat @user.firstName " " @user.lastName}}
</p>
{{! With dynamic values }}
<img
src={{concat "/images/" @category "/" @filename ".jpg"}}
alt={{concat "Image of " @title}}
/>
</template>
}
```
## Action Helpers (fn)
```glimmer-js
// app/components/action-helpers.gjs
import Component from '@glimmer/component';
import { fn } from '@ember/helper'; // Built-in to Ember
import { on } from '@ember/modifier';
class ActionHelpers extends Component {
updateValue = (field, event) => {
this.args.onChange(field, event.target.value);
};
deleteItem = (id) => {
this.args.onDelete(id);
};
<template>
{{! Partial application with fn }}
<input {{on "input" (fn this.updateValue "email")}} />
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn this.deleteItem item.id)}}>
Delete
</button>
</li>
{{/each}}
</template>
}
```
## Conditional Helpers (if/unless)
```glimmer-js
// app/components/conditional-inline.gjs
import Component from '@glimmer/component';
import { if as ifHelper } from '@ember/helper'; // Built-in to Ember
class ConditionalInline extends Component {
<template>
{{! Ternary-like behavior }}
<span class={{ifHelper @isActive "active" "inactive"}}>
{{@user.name}}
</span>
{{! Conditional attribute }}
<button disabled={{ifHelper @isProcessing true}}>
{{ifHelper @isProcessing "Processing..." "Submit"}}
</button>
{{! With default value }}
<p>{{ifHelper @description @description "No description provided"}}</p>
</template>
}
```
## Practical Combinations
**Dynamic Classes:**
```glimmer-js
// app/components/dynamic-classes.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper } from '@ember/helper'; // Built-in to Ember
import { and, not } from 'ember-truth-helpers';
class DynamicClasses extends Component {
<template>
<div
class={{concat
"card "
(ifHelper @isPremium "premium ")
(ifHelper (and @isNew (not @isRead)) "unread ")
@customClass
}}
>
<h3>{{@title}}</h3>
</div>
</template>
}
```
**List Filtering:**
```glimmer-js
// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
import { fn, concat } from '@ember/helper';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
import { array } from 'ember-composable-helpers/helpers';
class FilteredList extends Component {
@tracked filter = 'all';
@cached
get filteredItems() {
if (this.filter === 'all') return this.args.items;
return this.args.items.filter((item) => item.status === this.filter);
}
<template>
<select {{on "change" (fn (mut this.filter) target.value)}}>
{{#each (array "all" "active" "pending" "completed") as |option|}}
<option value={{option}} selected={{eq this.filter option}}>
{{option}}
</option>
{{/each}}
</select>
{{#each this.filteredItems as |item|}}
<div class={{concat "item " item.status}}>
{{item.name}}
</div>
{{/each}}
</template>
}
```
## Complex Example
```glimmer-js
// app/components/user-profile-card.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper, fn } from '@ember/helper'; // Built-in to Ember
import { eq, not, and, or } from 'ember-truth-helpers';
import { hash, array, get } from 'ember-composable-helpers/helpers';
import { on } from '@ember/modifier';
class UserProfileCard extends Component {
updateField = (field, value) => {
this.args.onUpdate(field, value);
};
<template>
<div
class={{concat
"profile-card "
(ifHelper @user.isPremium "premium ")
(ifHelper (and @user.isOnline (not @user.isAway)) "online ")
}}
>
<h2>{{concat @user.firstName " " @user.lastName}}</h2>
{{#if (or (eq @user.role "admin") (eq @user.role "moderator"))}}
<span class="badge">
{{get (hash admin="Administrator" moderator="Moderator") @user.role}}
</span>
{{/if}}
{{#if (and @canEdit (not @user.locked))}}
<div class="actions">
{{#each (array "profile" "settings" "privacy") as |section|}}
<button {{on "click" (fn this.updateField "activeSection" section)}}>
Edit
{{section}}
</button>
{{/each}}
</div>
{{/if}}
<p class={{ifHelper @user.verified "verified" "unverified"}}>
{{ifHelper @user.bio @user.bio "No bio provided"}}
</p>
</div>
</template>
}
```
## Performance Impact
- **Library helpers**: ~0% overhead (compiled into efficient bytecode)
- **Custom helpers**: 5-15% overhead per helper call
- **Inline logic**: Cleaner templates, better tree-shaking
## When to Use
- **Library helpers**: For all common operations (equality, logic, arrays, strings)
- **Custom helpers**: Only for domain-specific logic not covered by library helpers
- **Component logic**: For complex operations that need @cached or multiple dependencies
## Complete Helper Reference
**Note:** These helpers will be built into Ember 7 core. Until then:
**Actually Built-in to Ember (from `@ember/helper`):**
- `concat` - Concatenate strings
- `fn` - Partial application / bind arguments
- `if` - Ternary-like conditional value
- `mut` - Create settable binding (use sparingly)
**From `ember-truth-helpers` package:**
- `eq` - Equality (===)
- `not` - Negation (!)
- `and` - Logical AND
- `or` - Logical OR
- `lt`, `lte`, `gt`, `gte` - Numeric comparisons
**From `ember-composable-helpers` package:**
- `array` - Create array inline
- `hash` - Create object inline
- `get` - Dynamic property access
## References
- [Ember Built-in Helpers](https://guides.emberjs.com/release/templates/built-in-helpers/)
- [Template Helpers API](https://api.emberjs.com/ember/release/modules/@ember%2Fhelper)
- [fn Helper Guide](https://guides.emberjs.com/release/components/helper-functions/)
- [ember-truth-helpers](https://github.com/jmurphyau/ember-truth-helpers)
- [ember-composable-helpers](https://github.com/DockYard/ember-composable-helpers)

View File

@@ -0,0 +1,258 @@
---
title: Compose Helpers for Reusable Logic
impact: MEDIUM-HIGH
impactDescription: Better code reuse and testability
tags: helpers, composition, functions, pipes, reusability
---
## Compose Helpers for Reusable Logic
Compose helpers to create reusable, testable logic that can be combined in templates and components.
**Incorrect (logic duplicated in templates):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{uppercase (truncate @user.name 20)}}</h1>
{{#if (and @user.isActive (not @user.isDeleted))}}
<span class="status">Active</span>
{{/if}}
<p>{{lowercase @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Correct (composed helpers):**
```javascript
// app/helpers/display-name.js
export function displayName(name, { maxLength = 20 } = {}) {
if (!name) return '';
const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
return truncated.toUpperCase();
}
```
```javascript
// app/helpers/is-visible-user.js
export function isVisibleUser(user) {
return user && user.isActive && !user.isDeleted;
}
```
```javascript
// app/helpers/format-email.js
export function formatEmail(email) {
return email?.toLowerCase() || '';
}
```
```glimmer-js
// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
import { isVisibleUser } from '../helpers/is-visible-user';
import { formatEmail } from '../helpers/format-email';
<template>
<div class="profile">
<h1>{{displayName @user.name}}</h1>
{{#if (isVisibleUser @user)}}
<span class="status">Active</span>
{{/if}}
<p>{{formatEmail @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Functional composition with pipe helper:**
```javascript
// app/helpers/pipe.js
export function pipe(...fns) {
return (value) => fns.reduce((acc, fn) => fn(acc), value);
}
```
**Or use a compose helper:**
```javascript
// app/helpers/compose.js
export function compose(...helperFns) {
return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}
```
**Usage:**
```glimmer-js
// app/components/text-processor.gjs
import { fn } from '@ember/helper';
// Individual helpers
const uppercase = (str) => str?.toUpperCase() || '';
const trim = (str) => str?.trim() || '';
const truncate = (str, length = 20) => str?.slice(0, length) || '';
<template>
{{! Compose multiple transformations }}
<div>
{{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
</div>
</template>
```
**Higher-order helpers:**
```javascript
// app/helpers/partial-apply.js
export function partialApply(fn, ...args) {
return (...moreArgs) => fn(...args, ...moreArgs);
}
```
```javascript
// app/helpers/map-by.js
export function mapBy(array, property) {
return array?.map((item) => item[property]) || [];
}
```
```glimmer-js
// Usage in template
import { mapBy } from '../helpers/map-by';
import { partialApply } from '../helpers/partial-apply';
<template>
{{! Extract property from array }}
<ul>
{{#each (mapBy @users "name") as |name|}}
<li>{{name}}</li>
{{/each}}
</ul>
{{! Partial application }}
{{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
<span>Price: {{formatTwoDecimals @price}}</span>
{{/let}}
</template>
```
**Chainable transformation helpers:**
```javascript
// app/helpers/transform.js
class Transform {
constructor(value) {
this.value = value;
}
filter(fn) {
this.value = this.value?.filter(fn) || [];
return this;
}
map(fn) {
this.value = this.value?.map(fn) || [];
return this;
}
sort(fn) {
this.value = [...(this.value || [])].sort(fn);
return this;
}
take(n) {
this.value = this.value?.slice(0, n) || [];
return this;
}
get result() {
return this.value;
}
}
export function transform(value) {
return new Transform(value);
}
```
```glimmer-js
// Usage
import { transform } from '../helpers/transform';
function filter(items) {
return items
.filter((item) => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
.take(10).result;
}
<template>
{{#let (transform @items) as |t|}}
{{#each (filter t) as |item|}}
<div>{{item.name}}</div>
{{/each}}
{{/let}}
</template>
```
**Conditional composition:**
```javascript
// app/helpers/when.js
export function when(condition, trueFn, falseFn) {
return condition ? trueFn() : falseFn ? falseFn() : null;
}
```
```javascript
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
return !condition ? falseFn() : trueFn ? trueFn() : null;
}
```
**Testing composed helpers:**
```javascript
// tests/helpers/display-name-test.js
import { module, test } from 'qunit';
import { displayName } from 'my-app/helpers/display-name';
module('Unit | Helper | display-name', function () {
test('it formats name correctly', function (assert) {
assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
});
test('it truncates long names', function (assert) {
assert.strictEqual(
displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
'A VERY LON...',
);
});
test('it handles null', function (assert) {
assert.strictEqual(displayName(null), '');
});
});
```
Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)

View File

@@ -0,0 +1,145 @@
---
title: No helper() Wrapper for Plain Functions
impact: LOW-MEDIUM
impactDescription: Simpler code, better performance
tags: helpers, templates, modern-ember
---
## No helper() Wrapper for Plain Functions
In modern Ember, plain functions can be used directly as helpers without wrapping them with `helper()`. The `helper()` wrapper is legacy and adds unnecessary complexity.
**Incorrect (using helper() wrapper):**
```javascript
// app/utils/format-date.js
import { helper } from '@ember/component/helper';
function formatDate([date]) {
return new Date(date).toLocaleDateString();
}
export default helper(formatDate);
```
**Correct (plain function):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Usage in templates:**
```glimmer-js
// app/components/post-card.gjs
import { formatDate } from '../utils/format-date';
<template>
<article>
<h2>{{@post.title}}</h2>
<time>{{formatDate @post.publishedAt}}</time>
</article>
</template>
```
**With Multiple Arguments:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<span class="price">
{{formatCurrency @amount @currency}}
</span>
</template>
```
**For Helpers that Need Services (use class-based):**
When you need dependency injection, use a class instead of `helper()`:
```javascript
// app/utils/format-relative-time.js
export class FormatRelativeTime {
constructor(owner) {
this.intl = owner.lookup('service:intl');
}
compute(date) {
return this.intl.formatRelative(date);
}
}
```
**Why Avoid helper():**
1. **Simpler**: Plain functions are easier to understand
2. **Standard JavaScript**: No Ember-specific wrapper needed
3. **Better Testing**: Plain functions are easier to test
4. **Performance**: No wrapper overhead
5. **Modern Pattern**: Aligns with modern Ember conventions
**Migration from helper():**
```javascript
// Before
import { helper } from '@ember/component/helper';
function capitalize([text]) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export default helper(capitalize);
// After
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
```
**Common Helper Patterns:**
```javascript
// app/utils/string-helpers.js
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function truncate(text, length = 50) {
if (text.length <= length) return text;
return text.slice(0, length) + '...';
}
export function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
```
```glimmer-js
// Usage
import { capitalize, truncate, pluralize } from '../utils/string-helpers';
<template>
<h1>{{capitalize @title}}</h1>
<p>{{truncate @description 100}}</p>
<span>{{@count}} {{pluralize @count "item" "items"}}</span>
</template>
```
Plain functions are the modern way to create helpers in Ember. Only use classes when you need dependency injection.
Reference: [Ember Helpers - Plain Functions](https://guides.emberjs.com/release/components/helper-functions/)

View File

@@ -0,0 +1,250 @@
---
title: Use {{on}} Modifier Instead of Event Handler Properties
impact: MEDIUM
impactDescription: Better performance and clearer event handling
tags: performance, events, modifiers, best-practices
---
## Use {{on}} Modifier Instead of Event Handler Properties
Always use the `{{on}}` modifier for event handling instead of HTML event handler properties. The `{{on}}` modifier provides better memory management, automatic cleanup, and clearer intent.
**Why {{on}} is Better:**
- Automatic cleanup when element is removed (prevents memory leaks)
- Supports event options (`capture`, `passive`, `once`)
- More explicit and searchable in templates
**Incorrect (HTML event properties):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button onclick={{this.handleClick}}>
Click Me
</button>
</template>
}
```
**Correct ({{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button {{on "click" this.handleClick}}>
Click Me
</button>
</template>
}
```
### Event Options
The `{{on}}` modifier supports standard event listener options:
```glimmer-js
// app/components/scrollable.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Scrollable extends Component {
@action
handleScroll(event) {
console.log('scrolled', event.target.scrollTop);
}
<template>
{{! passive: true improves scroll performance }}
<div {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Available options:**
- `capture` - Use capture phase instead of bubble phase
- `once` - Remove listener after first invocation
- `passive` - Indicates handler won't call `preventDefault()` (better scroll performance)
### Handling Multiple Events
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class InputField extends Component {
@action
handleFocus() {
console.log('focused');
}
@action
handleBlur() {
console.log('blurred');
}
@action
handleInput(event) {
this.args.onChange?.(event.target.value);
}
<template>
<input
type="text"
value={{@value}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
/>
</template>
}
```
### Preventing Default and Stopping Propagation
Handle these in your action, not in the template:
```glimmer-js
// app/components/form.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Form extends Component {
@action
handleSubmit(event) {
event.preventDefault(); // Prevent page reload
event.stopPropagation(); // Stop event bubbling if needed
this.args.onSubmit?.(/* form data */);
}
<template>
<form {{on "submit" this.handleSubmit}}>
<button type="submit">Submit</button>
</form>
</template>
}
```
### Keyboard Events
```glimmer-js
// app/components/keyboard-nav.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class KeyboardNav extends Component {
@action
handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.args.onActivate?.();
}
if (event.key === 'Escape') {
this.args.onCancel?.();
}
}
<template>
<div role="button" tabindex="0" {{on "keydown" this.handleKeyDown}}>
{{yield}}
</div>
</template>
}
```
### Performance Tip: Event Delegation
For lists with many items, use event delegation on the parent:
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class TodoList extends Component {
@action
handleClick(event) {
// Find which todo was clicked
const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
if (todoId) {
this.args.onTodoClick?.(todoId);
}
}
<template>
{{! Single listener for all todos - better than one per item }}
<ul {{on "click" this.handleClick}}>
{{#each @todos as |todo|}}
<li data-todo-id={{todo.id}}>
{{todo.title}}
</li>
{{/each}}
</ul>
</template>
}
```
### Common Pitfalls
**❌ Don't bind directly without @action:**
```glimmer-js
// This won't work - loses 'this' context
<button {{on "click" this.myMethod}}>Bad</button>
```
**✅ Use @action decorator:**
```glimmer-js
@action
myMethod() {
// 'this' is correctly bound
}
<button {{on "click" this.myMethod}}>Good</button>
```
**❌ Don't use string event handlers:**
```glimmer-js
{{! Security risk and doesn't work in strict mode }}
<button onclick="handleClick()">Bad</button>
```
Always use the `{{on}}` modifier for cleaner, safer, and more performant event handling in Ember applications.
**References:**
- [Ember Modifiers Guide](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
- [{{on}} Modifier RFC](https://github.com/emberjs/rfcs/blob/master/text/0471-on-modifier.md)
- [Event Listener Options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters)

View File

@@ -0,0 +1,45 @@
---
title: Use Route-Based Code Splitting
impact: CRITICAL
impactDescription: 30-70% initial bundle reduction
tags: routes, lazy-loading, embroider, bundle-size
---
## Use Route-Based Code Splitting
With Embroider's route-based code splitting, routes and their components are automatically split into separate chunks, loaded only when needed.
**Incorrect (everything in main bundle):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
// No optimization
});
return app.toTree();
};
```
**Correct (Embroider with Vite and route splitting):**
```javascript
// ember-cli-build.js
const { Vite } = require('@embroider/vite');
module.exports = require('@embroider/compat').compatBuild(app, Vite, {
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
staticHelpers: true,
staticModifiers: true,
staticComponents: true,
splitAtRoutes: ['admin', 'reports', 'settings'], // Routes to split
});
```
Embroider with `splitAtRoutes` creates separate bundles for specified routes, reducing initial load time by 30-70%.
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)

View File

@@ -0,0 +1,47 @@
---
title: Use Loading Substates for Better UX
impact: CRITICAL
impactDescription: Perceived performance improvement
tags: routes, loading, ux, performance
---
## Use Loading Substates for Better UX
Implement loading substates to show immediate feedback while data loads, preventing blank screens and improving perceived performance.
**Incorrect (no loading state):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
async model() {
return this.store.request({ url: '/posts' });
}
}
```
**Correct (with loading substate):**
```glimmer-js
// app/routes/posts-loading.gjs
import { LoadingSpinner } from './loading-spinner';
<template>
<div class="loading-spinner" role="status" aria-live="polite">
<span class="sr-only">Loading posts...</span>
<LoadingSpinner />
</div>
</template>
```
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
model() {
// Return promise directly - Ember will show posts-loading template
return this.store.request({ url: '/posts' });
}
}
```
Ember automatically renders `{route-name}-loading` route templates while the model promise resolves, providing better UX without extra code.

View File

@@ -0,0 +1,230 @@
---
title: Implement Smart Route Model Caching
impact: MEDIUM-HIGH
impactDescription: Reduce redundant API calls and improve UX
tags: routes, caching, performance, model
---
## Implement Smart Route Model Caching
Implement intelligent model caching strategies to reduce redundant API calls and improve user experience.
**Incorrect (always fetches fresh data):**
```glimmer-js
// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostRoute extends Route {
@service store;
model(params) {
// Always makes API call, even if we just loaded this post
return this.store.request({ url: `/posts/${params.post_id}` });
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**Correct (with smart caching):**
```glimmer-js
// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostRoute extends Route {
@service store;
model(params) {
// Check cache first
const cached = this.store.cache.peek({
type: 'post',
id: params.post_id,
});
// Return cached if fresh (less than 5 minutes old)
if (cached && this.isCacheFresh(cached)) {
return cached;
}
// Fetch fresh data
return this.store.request({
url: `/posts/${params.post_id}`,
options: { reload: true },
});
}
isCacheFresh(record) {
const cacheTime = record.meta?.cachedAt || 0;
const fiveMinutes = 5 * 60 * 1000;
return Date.now() - cacheTime < fiveMinutes;
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**Service-based caching layer:**
```javascript
// app/services/post-cache.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedMap } from 'tracked-built-ins';
export default class PostCacheService extends Service {
@service store;
cache = new TrackedMap();
cacheTimes = new Map();
cacheTimeout = 5 * 60 * 1000; // 5 minutes
async getPost(id, { forceRefresh = false } = {}) {
const now = Date.now();
const cacheTime = this.cacheTimes.get(id) || 0;
const isFresh = now - cacheTime < this.cacheTimeout;
if (!forceRefresh && isFresh && this.cache.has(id)) {
return this.cache.get(id);
}
const post = await this.store.request({ url: `/posts/${id}` });
this.cache.set(id, post);
this.cacheTimes.set(id, now);
return post;
}
invalidate(id) {
this.cache.delete(id);
this.cacheTimes.delete(id);
}
invalidateAll() {
this.cache.clear();
this.cacheTimes.clear();
}
}
```
```glimmer-js
// app/routes/post.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostRoute extends Route {
@service postCache;
model(params) {
return this.postCache.getPost(params.post_id);
}
// Refresh data when returning to route
async activate() {
super.activate(...arguments);
const params = this.paramsFor('post');
await this.postCache.getPost(params.post_id, { forceRefresh: true });
}
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
}
```
**Using query params for cache control:**
```glimmer-js
// app/routes/posts.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostsRoute extends Route {
@service store;
queryParams = {
refresh: { refreshModel: true },
};
model(params) {
const options = params.refresh ? { reload: true } : { backgroundReload: true };
return this.store.request({
url: '/posts',
options,
});
}
<template>
<div class="posts">
<button {{on "click" (fn this.refresh)}}>
Refresh
</button>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</div>
{{outlet}}
</template>
}
```
**Background refresh pattern:**
```glimmer-js
// app/routes/dashboard.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
async model() {
// Return cached data immediately
const cached = this.store.cache.peek({ type: 'dashboard' });
// Refresh in background
this.store.request({
url: '/dashboard',
options: { backgroundReload: true },
});
return cached || this.store.request({ url: '/dashboard' });
}
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<div>Stats: {{@model.stats}}</div>
</div>
{{outlet}}
</template>
}
```
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
Reference: [WarpDrive Caching](https://warp-drive.io/)

View File

@@ -0,0 +1,54 @@
---
title: Parallel Data Loading in Model Hooks
impact: CRITICAL
impactDescription: 2-10× improvement
tags: routes, data-fetching, parallelization, performance
---
## Parallel Data Loading in Model Hooks
When fetching multiple independent data sources in a route's model hook, use `Promise.all()` or RSVP.hash() to load them in parallel instead of sequentially.
`export default` in these route examples is intentional because route modules are discovered through resolver lookup. In hybrid `.gjs`/`.hbs` codebases, keep route defaults and add named exports only when you need explicit imports elsewhere.
**Incorrect (sequential loading, 3 round trips):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
async model() {
const user = await this.store.request({ url: '/users/me' });
const posts = await this.store.request({ url: '/posts?recent=true' });
const notifications = await this.store.request({ url: '/notifications?unread=true' });
return { user, posts, notifications };
}
}
```
**Correct (parallel loading, 1 round trip):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
export default class DashboardRoute extends Route {
@service store;
model() {
return hash({
user: this.store.request({ url: '/users/me' }),
posts: this.store.request({ url: '/posts?recent=true' }),
notifications: this.store.request({ url: '/notifications?unread=true' }),
});
}
}
```
Using `hash()` from RSVP allows Ember to resolve all promises concurrently, significantly reducing load time.

View File

@@ -0,0 +1,105 @@
---
title: Use Route Templates with Co-located Syntax
impact: MEDIUM-HIGH
impactDescription: Better code organization and maintainability
tags: routes, templates, gjs, co-location
---
## Use Route Templates with Co-located Syntax
Use co-located route templates with modern gjs syntax for better organization and maintainability.
**Incorrect (separate template file - old pattern):**
```glimmer-js
// app/routes/posts.js (separate file)
import Route from '@ember/routing/route';
export default class PostsRoute extends Route {
model() {
return this.store.request({ url: '/posts' });
}
}
// app/templates/posts.gjs (separate template file)
<template>
<h1>Posts</h1>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</template>
```
**Correct (co-located route template):**
```glimmer-js
// app/routes/posts.gjs
import Route from '@ember/routing/route';
export default class PostsRoute extends Route {
model() {
return this.store.request({ url: '/posts' });
}
<template>
<h1>Posts</h1>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
{{outlet}}
</template>
}
```
**With loading and error states:**
```glimmer-js
// app/routes/posts.gjs
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PostsRoute extends Route {
@service store;
model() {
return this.store.request({ url: '/posts' });
}
<template>
<div class="posts-page">
<h1>Posts</h1>
{{#if @model}}
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
{{/if}}
{{outlet}}
</div>
</template>
}
```
**Template-only routes:**
```glimmer-js
// app/routes/about.gjs
<template>
<div class="about-page">
<h1>About Us</h1>
<p>Welcome to our application!</p>
</div>
</template>
```
Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)

View File

@@ -0,0 +1,98 @@
---
title: Cache API Responses in Services
impact: MEDIUM-HIGH
impactDescription: 50-90% reduction in duplicate requests
tags: services, caching, performance, api
---
## Cache API Responses in Services
Cache API responses in services to avoid duplicate network requests. Use tracked properties to make the cache reactive.
**Incorrect (no caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
export default class UserService extends Service {
@service store;
async getCurrentUser() {
// Fetches from API every time
return this.store.request({ url: '/users/me' });
}
}
```
**Correct (with caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedMap } from 'tracked-built-ins';
export default class UserService extends Service {
@service store;
@tracked currentUser = null;
cache = new TrackedMap();
async getCurrentUser() {
if (!this.currentUser) {
const response = await this.store.request({ url: '/users/me' });
this.currentUser = response.content.data;
}
return this.currentUser;
}
async getUser(id) {
if (!this.cache.has(id)) {
const response = await this.store.request({ url: `/users/${id}` });
this.cache.set(id, response.content.data);
}
return this.cache.get(id);
}
clearCache() {
this.currentUser = null;
this.cache.clear();
}
}
```
**For time-based cache invalidation:**
```javascript
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class DataService extends Service {
@tracked _cache = null;
_cacheTimestamp = null;
_cacheDuration = 5 * 60 * 1000; // 5 minutes
async getData() {
const now = Date.now();
const isCacheValid =
this._cache && this._cacheTimestamp && now - this._cacheTimestamp < this._cacheDuration;
if (!isCacheValid) {
this._cache = await this.fetchData();
this._cacheTimestamp = now;
}
return this._cache;
}
async fetchData() {
const response = await fetch('/api/data');
return response.json();
}
}
```
Caching in services prevents duplicate API requests and improves performance significantly.

View File

@@ -0,0 +1,342 @@
---
title: Implement Robust Data Requesting Patterns
impact: HIGH
impactDescription: Prevents request waterfalls and race conditions in data flows
tags: services, data-fetching, concurrency, cancellation, reliability
---
## Implement Robust Data Requesting Patterns
Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic.
`export default` in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid `.gjs`/`.hbs` codebases, you can pair named exports with a default alias where needed.
## Problem
Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests.
**Incorrect:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
export default class DashboardRoute extends Route {
async model() {
// Sequential waterfall - slow!
const user = await this.store.request({ url: '/users/me' });
const posts = await this.store.request({ url: '/posts' });
const notifications = await this.store.request({ url: '/notifications' });
// No error handling
// No cancellation
return { user, posts, notifications };
}
}
```
## Solution: Parallel Requests
Use `RSVP.hash` or `Promise.all` for parallel loading:
**Correct (parallelized model loading):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class DashboardRoute extends Route {
async model() {
return hash({
user: this.store.request({ url: '/users/me' }),
posts: this.store.request({ url: '/posts?recent=true' }),
notifications: this.store.request({ url: '/notifications?unread=true' }),
});
}
}
```
## Error Handling Pattern
Handle errors gracefully with fallbacks:
```javascript
// app/services/api.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ApiService extends Service {
@service store;
@tracked lastError = null;
async fetchWithFallback(url, fallback = null) {
try {
const response = await this.store.request({ url });
this.lastError = null;
return response.content;
} catch (error) {
this.lastError = error.message;
console.error(`API Error fetching ${url}:`, error);
return fallback;
}
}
async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await this.store.request({ url });
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
}
}
}
}
```
## Request Cancellation with AbortController
Prevent race conditions by canceling stale requests:
```glimmer-js
// app/components/search-results.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { restartableTask, timeout } from 'ember-concurrency';
class SearchResults extends Component {
@service store;
@tracked results = [];
// Automatically cancels previous searches
@restartableTask
*searchTask(query) {
yield timeout(300); // Debounce
try {
const response = yield this.store.request({
url: `/search?q=${encodeURIComponent(query)}`,
});
this.results = response.content;
} catch (error) {
if (error.name !== 'TaskCancelation') {
console.error('Search failed:', error);
}
}
}
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform @value)}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="loading">Searching...</div>
{{else}}
<ul>
{{#each this.results as |result|}}
<li>{{result.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
## Manual AbortController Pattern
For non-ember-concurrency scenarios:
```javascript
// app/services/data-fetcher.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class DataFetcherService extends Service {
@service store;
@tracked data = null;
@tracked isLoading = false;
abortController = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.abortController?.abort();
});
}
async fetch(url) {
// Cancel previous request
this.abortController?.abort();
this.abortController = new AbortController();
this.isLoading = true;
try {
// Note: WarpDrive handles AbortSignal internally
const response = await this.store.request({
url,
signal: this.abortController.signal,
});
this.data = response.content;
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
} finally {
this.isLoading = false;
}
}
}
```
## Dependent Requests Pattern
When requests depend on previous results:
```javascript
// app/routes/post.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class PostRoute extends Route {
async model({ post_id }) {
// First fetch the post
const post = await this.store.request({
url: `/posts/${post_id}`,
});
// Then fetch related data in parallel
return hash({
post,
author: this.store.request({
url: `/users/${post.content.authorId}`,
}),
comments: this.store.request({
url: `/posts/${post_id}/comments`,
}),
relatedPosts: this.store.request({
url: `/posts/${post_id}/related`,
}),
});
}
}
```
## Polling Pattern
For real-time data updates:
```javascript
// app/services/live-data.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class LiveDataService extends Service {
@service store;
@tracked data = null;
intervalId = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.stopPolling();
});
}
startPolling(url, interval = 5000) {
this.stopPolling();
this.poll(url); // Initial fetch
this.intervalId = setInterval(() => this.poll(url), interval);
}
async poll(url) {
try {
const response = await this.store.request({ url });
this.data = response.content;
} catch (error) {
console.error('Polling error:', error);
}
}
stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
```
## Batch Requests
Optimize multiple similar requests:
```javascript
// app/services/batch-loader.js
import Service, { service } from '@ember/service';
export default class BatchLoaderService extends Service {
@service store;
pendingIds = new Set();
batchTimeout = null;
async loadUser(id) {
this.pendingIds.add(id);
if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => this.executeBatch(), 50);
}
// Return a promise that resolves when batch completes
return new Promise((resolve) => {
this.registerCallback(id, resolve);
});
}
async executeBatch() {
const ids = Array.from(this.pendingIds);
this.pendingIds.clear();
this.batchTimeout = null;
const response = await this.store.request({
url: `/users?ids=${ids.join(',')}`,
});
// Resolve all pending promises
response.content.forEach((user) => {
this.resolveCallback(user.id, user);
});
}
}
```
## Performance Impact
- **Parallel requests (RSVP.hash)**: 60-80% faster than sequential
- **Request cancellation**: Prevents memory leaks and race conditions
- **Retry logic**: Improves reliability with < 5% overhead
- **Batch loading**: 40-70% reduction in requests
## When to Use
- **RSVP.hash**: Independent data that can load in parallel
- **ember-concurrency**: Search, autocomplete, or user-driven requests
- **AbortController**: Long-running requests that may become stale
- **Retry logic**: Critical data with transient network issues
- **Batch loading**: Loading many similar items (N+1 scenarios)
## References
- [WarpDrive Documentation](https://warp-drive.io/)
- [ember-concurrency](https://ember-concurrency.com/)
- [RSVP.js](https://github.com/tildeio/rsvp.js)
- [AbortController MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)

View File

@@ -0,0 +1,129 @@
---
title: Optimize WarpDrive Queries
impact: MEDIUM-HIGH
impactDescription: 40-70% reduction in API calls
tags: warp-drive, performance, api, optimization
---
## Optimize WarpDrive Queries
Use WarpDrive's request features effectively to reduce API calls and load only the data you need.
**Incorrect (multiple queries, overfetching):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
async model() {
// Loads all posts (could be thousands)
const response = await this.store.request({ url: '/posts' });
const posts = response.content.data;
// Then filters in memory
return posts.filter((post) => post.attributes.status === 'published');
}
}
```
**Correct (filtered query with pagination):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
queryParams = {
page: { refreshModel: true },
filter: { refreshModel: true },
};
model(params) {
// Server-side filtering and pagination
return this.store.request({
url: '/posts',
data: {
filter: {
status: 'published',
},
page: {
number: params.page || 1,
size: 20,
},
include: 'author', // Sideload related data
fields: {
// Sparse fieldsets
posts: 'title,excerpt,publishedAt,author',
users: 'name,avatar',
},
},
});
}
}
```
**Use request with includes for single records:**
```javascript
// app/routes/post.js
export default class PostRoute extends Route {
@service store;
model(params) {
return this.store.request({
url: `/posts/${params.post_id}`,
data: {
include: 'author,comments.user', // Nested relationships
},
});
}
}
```
**For frequently accessed data, use cache lookups:**
```javascript
// app/components/user-badge.js
class UserBadge extends Component {
@service store;
get user() {
// Check cache first, avoiding API call if already loaded
const cached = this.store.cache.peek({
type: 'user',
id: this.args.userId,
});
if (cached) {
return cached;
}
// Only fetch if not in cache
return this.store.request({
url: `/users/${this.args.userId}`,
});
}
}
```
**Use request options for custom queries:**
```javascript
model() {
return this.store.request({
url: '/posts',
data: {
include: 'author,tags',
customParam: 'value'
},
options: {
reload: true // Bypass cache
}
});
}
```
Efficient WarpDrive usage reduces network overhead and improves application performance significantly.
Reference: [WarpDrive Documentation](https://warp-drive.io/)

View File

@@ -0,0 +1,460 @@
---
title: Manage Service Owner and Linkage Patterns
impact: MEDIUM-HIGH
impactDescription: Better service organization and dependency management
tags: services, owner, linkage, dependency-injection, architecture
---
## Manage Service Owner and Linkage Patterns
Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.
### Owner and Linkage Fundamentals
**Incorrect (manual service instantiation):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import ApiService from '../services/api';
class UserProfile extends Component {
// ❌ Creates orphaned instance without owner
api = new ApiService();
async loadUser() {
// Won't have access to other services or owner features
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
**Correct (proper service injection with owner):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class UserProfile extends Component {
// ✅ Proper injection with owner linkage
@service api;
async loadUser() {
// Has full owner context and can inject other services
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
### Manual Owner Passing (Without Libraries)
**Creating instances with owner:**
```glimmer-js
// app/components/data-processor.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { service } from '@ember/service';
class DataTransformer {
@service store;
transform(data) {
// Can use injected services because it has an owner
return this.store.request({ url: '/transform', data });
}
}
class DataProcessor extends Component {
@service('store') storeService;
constructor(owner, args) {
super(owner, args);
// Manual instantiation with owner linkage
this.transformer = new DataTransformer();
setOwner(this.transformer, getOwner(this));
}
processData(data) {
// transformer can now access services
return this.transformer.transform(data);
}
<template>
<div>Processing...</div>
</template>
}
```
**Factory pattern with owner:**
```javascript
// app/utils/logger-factory.js
import { getOwner } from '@ember/application';
class Logger {
constructor(owner, context) {
this.owner = owner;
this.context = context;
}
get config() {
// Access configuration service via owner
return getOwner(this).lookup('service:config');
}
log(message) {
if (this.config.enableLogging) {
console.log(`[${this.context}]`, message);
}
}
}
export function createLogger(owner, context) {
return new Logger(owner, context);
}
```
```glimmer-js
// Usage in component
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { createLogger } from '../utils/logger-factory';
class My extends Component {
logger = createLogger(getOwner(this), 'MyComponent');
performAction() {
this.logger.log('Action performed');
}
<template>
<button {{on "click" this.performAction}}>Do Something</button>
</template>
}
```
### Owner Passing with Modern Libraries
**Using reactiveweb's link() for ownership and destruction:**
The `link()` function from `reactiveweb` provides both ownership transfer and automatic destruction linkage.
```glimmer-js
// app/components/advanced-form.gjs
import Component from '@glimmer/component';
import { link } from 'reactiveweb/link';
class ValidationService {
validate(data) {
// Validation logic
return data.email && data.email.includes('@');
}
}
class FormStateManager {
data = { email: '' };
updateEmail(value) {
this.data.email = value;
}
}
export class AdvancedForm extends Component {
// link() handles both owner and destruction automatically
validation = link(this, () => new ValidationService());
formState = link(this, () => new FormStateManager());
get isValid() {
return this.validation.validate(this.formState.data);
}
<template>
<form>
<input value={{this.formState.data.email}} />
{{#if (not this.isValid)}}
<span>Invalid form</span>
{{/if}}
</form>
</template>
}
```
**Why use link():**
- Automatically transfers owner from parent to child instance
- Registers destructor so child is cleaned up when parent is destroyed
- No manual `setOwner` or `registerDestructor` calls needed
- See [RFC #1067](https://github.com/emberjs/rfcs/pull/1067) for the proposal and reasoning
- Documentation: https://reactive.nullvoxpopuli.com/functions/link.link.html
### Services Outside app/services Directory
**Using createService from ember-primitives:**
```glimmer-js
// app/components/analytics-tracker.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { createService } from 'ember-primitives/utils';
// Define service logic as a plain function
function AnalyticsService() {
let events = [];
return {
get events() {
return events;
},
track(event) {
events.push({ ...event, timestamp: Date.now() });
// Send to analytics endpoint
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(event),
});
},
};
}
export class AnalyticsTracker extends Component {
// createService handles owner linkage and cleanup automatically
analytics = createService(this, AnalyticsService);
<template>
<div>Tracking {{this.analytics.events.length}} events</div>
</template>
}
```
**Why createService:**
- No need to extend Service class
- Automatic owner linkage and cleanup
- Simpler than manual setOwner/registerDestructor
- Documentation: https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md
**Co-located services with components:**
```javascript
// app/components/shopping-cart/service.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins';
import { action } from '@ember/object';
export class CartService extends Service {
@tracked items = new TrackedArray([]);
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(id) {
const index = this.items.findIndex((item) => item.id === id);
if (index > -1) this.items.splice(index, 1);
}
@action
clear() {
this.items.clear();
}
}
```
```glimmer-js
// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { CartService } from './service';
class ShoppingCart extends Component {
cart = (() => {
const instance = new CartService();
setOwner(instance, getOwner(this));
return instance;
})();
<template>
<div class="cart">
<h3>Cart ({{this.cart.items.length}} items)</h3>
<div>Total: ${{this.cart.total}}</div>
{{#each this.cart.items as |item|}}
<div class="cart-item">
{{item.name}}
- ${{item.price}}
<button {{on "click" (fn this.cart.removeItem item.id)}}>
Remove
</button>
</div>
{{/each}}
<button {{on "click" this.cart.clear}}>Clear Cart</button>
</div>
</template>
}
```
**Service-like utilities in utils/ directory:**
```javascript
// app/utils/notification-manager.js
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
import { setOwner } from '@ember/application';
export class NotificationManager {
@tracked notifications = new TrackedArray([]);
constructor(owner) {
setOwner(this, owner);
}
@action
add(message, type = 'info') {
const notification = {
id: Math.random().toString(36),
message,
type,
timestamp: Date.now(),
};
this.notifications.push(notification);
// Auto-dismiss after 5 seconds
setTimeout(() => this.dismiss(notification.id), 5000);
}
@action
dismiss(id) {
const index = this.notifications.findIndex((n) => n.id === id);
if (index > -1) this.notifications.splice(index, 1);
}
}
```
```glimmer-js
// app/components/notification-container.gjs
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { NotificationManager } from '../utils/notification-manager';
class NotificationContainer extends Component {
notifications = new NotificationManager(getOwner(this));
<template>
<div class="notifications">
{{#each this.notifications.notifications as |notif|}}
<div class="notification notification-{{notif.type}}">
{{notif.message}}
<button {{on "click" (fn this.notifications.dismiss notif.id)}}>
×
</button>
</div>
{{/each}}
</div>
{{! Example usage }}
<button {{on "click" (fn this.notifications.add "Success!" "success")}}>
Show Notification
</button>
</template>
}
```
### Registering Custom Services Dynamically
**Runtime service registration:**
```javascript
// app/instance-initializers/dynamic-services.js
export function initialize(appInstance) {
// Register service dynamically without app/services file
appInstance.register(
'service:feature-flags',
class FeatureFlagsService {
flags = {
newDashboard: true,
betaFeatures: false,
};
isEnabled(flag) {
return this.flags[flag] || false;
}
},
);
// Make it a singleton
appInstance.inject('route', 'featureFlags', 'service:feature-flags');
appInstance.inject('component', 'featureFlags', 'service:feature-flags');
}
export default {
initialize,
};
```
**Using registered services:**
```glimmer-js
// app/components/feature-gated.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class FeatureGated extends Component {
@service featureFlags;
get shouldShow() {
return this.featureFlags.isEnabled(this.args.feature);
}
<template>
{{#if this.shouldShow}}
{{yield}}
{{else}}
<div class="feature-disabled">This feature is not available</div>
{{/if}}
</template>
}
```
### Best Practices
1. **Use @service decorator** for app/services - cleanest and most maintainable
2. **Use link() from reactiveweb** for ownership and destruction linkage
3. **Use createService from ember-primitives** for component-scoped services without extending Service class
4. **Manual owner passing** for utilities that need occasional service access
5. **Co-located services** for component-specific state that doesn't need global access
6. **Runtime registration** for dynamic services or testing scenarios
7. **Always use setOwner** when manually instantiating classes that need services
### When to Use Each Pattern
- **app/services**: Global singletons needed across the app
- **link() from reactiveweb**: When you need both owner and destruction linkage
- **createService from ember-primitives**: Component-scoped services without Service class
- **Co-located services**: Component-specific state, not needed elsewhere
- **Utils with owner**: Stateless utilities that occasionally need config/services
- **Runtime registration**: Dynamic configuration, feature flags, testing
Reference: [Ember Owner API](https://api.emberjs.com/ember/release/functions/@ember%2Fapplication/getOwner), [Dependency Injection](https://guides.emberjs.com/release/applications/dependency-injection/), [reactiveweb link()](https://reactive.nullvoxpopuli.com/functions/link.link.html), [ember-primitives createService](https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md)

View File

@@ -0,0 +1,119 @@
---
title: Use Services for Shared State
impact: MEDIUM-HIGH
impactDescription: Better state management and reusability
tags: services, state-management, dependency-injection
---
## Use Services for Shared State
Use services to manage shared state across components and routes instead of passing data through multiple layers or duplicating state.
**Incorrect (prop drilling):**
```glimmer-js
// app/routes/dashboard.gjs
export default class DashboardRoute extends Route {
model() {
return { currentTheme: 'dark' };
}
<template>
<Header @theme={{@model.currentTheme}} />
<Sidebar @theme={{@model.currentTheme}} />
<MainContent @theme={{@model.currentTheme}} />
</template>
}
```
**Correct (using service):**
```javascript
// app/services/theme.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ThemeService extends Service {
@tracked currentTheme = 'dark';
@action
setTheme(theme) {
this.currentTheme = theme;
localStorage.setItem('theme', theme);
}
@action
loadTheme() {
this.currentTheme = localStorage.getItem('theme') || 'dark';
}
}
```
```javascript
// app/components/header.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Header extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
```javascript
// app/components/sidebar.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Sidebar extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
Services provide centralized state management with automatic reactivity through tracked properties.
**For complex state, consider using Ember Data or ember-orbit:**
```javascript
// app/services/cart.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedArray } from 'tracked-built-ins';
import { cached } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class CartService extends Service {
@service store;
items = new TrackedArray([]);
@cached
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@cached
get itemCount() {
return this.items.length;
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(item) {
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
}
```
Reference: [Ember Services](https://guides.emberjs.com/release/services/)

View File

@@ -0,0 +1,94 @@
---
title: Avoid Heavy Computation in Templates
impact: MEDIUM
impactDescription: 40-60% reduction in render time
tags: templates, performance, getters, helpers
---
## Avoid Heavy Computation in Templates
Move expensive computations from templates to cached getters in the component class or in-scope functions for template-only components. Templates should only display data, not compute it. Keep templates easy for humans to read by minimizing nested function invocations.
**Why this matters:**
- Templates should be easy to read and understand
- Nested function calls create cognitive overhead
- Computations should be cached and reused, not recalculated on every render
- Template-only components (without `this`) need alternative patterns
**Incorrect (heavy computation in template):**
```glimmer-js
// app/components/stats.gjs
import { sum, map, div, max, multiply, sortBy } from '../helpers/math';
<template>
<div>
<p>Total: {{sum (map @items "price")}}</p>
<p>Average: {{div (sum (map @items "price")) @items.length}}</p>
<p>Max: {{max (map @items "price")}}</p>
{{#each (sortBy "name" @items) as |item|}}
<div>{{item.name}}: {{multiply item.price item.quantity}}</div>
{{/each}}
</div>
</template>
```
**Correct (computation in component with cached getters):**
```glimmer-js
// app/components/stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class Stats extends Component {
// @cached is useful when getters are accessed multiple times
// For single access, regular getters are sufficient
@cached
get total() {
return this.args.items.reduce((sum, item) => sum + item.price, 0);
}
get average() {
// No @cached needed if only accessed once in template
return this.args.items.length > 0 ? this.total / this.args.items.length : 0;
}
get maxPrice() {
return Math.max(...this.args.items.map((item) => item.price));
}
@cached
get sortedItems() {
// @cached useful here as it's used by itemsWithTotal
return [...this.args.items].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get itemsWithTotal() {
// @cached useful as accessed multiple times in {{#each}}
return this.sortedItems.map((item) => ({
...item,
total: item.price * item.quantity,
}));
}
<template>
<div>
<p>Total: {{this.total}}</p>
<p>Average: {{this.average}}</p>
<p>Max: {{this.maxPrice}}</p>
{{#each this.itemsWithTotal key="id" as |item|}}
<div>{{item.name}}: {{item.total}}</div>
{{/each}}
</div>
</template>
}
```
**Note on @cached**: Use `@cached` when a getter is accessed multiple times (like in `{{#each}}` loops or by other getters). For getters accessed only once, regular getters are sufficient and avoid unnecessary memoization overhead.
Moving computations to getters ensures they run only when dependencies change, not on every render. Templates remain clean and readable.

View File

@@ -0,0 +1,280 @@
---
title: Optimize Conditional Rendering
impact: HIGH
impactDescription: Reduces unnecessary rerenders in dynamic template branches
tags: templates, conditionals, rendering, performance, glimmer
---
## Optimize Conditional Rendering
Use efficient conditional rendering patterns to minimize unnecessary DOM updates and improve rendering performance.
## Problem
Inefficient conditional logic causes excessive re-renders, creates complex template code, and can lead to poor performance in lists and dynamic UIs.
**Incorrect:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{! Recomputes every time}}
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{/if}}
{{#if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{/if}}
{{#if (eq user.role "user")}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Solution
Use `{{#if}}` / `{{#else if}}` / `{{#else}}` chains and extract computed logic to getters for better performance and readability.
**Correct:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{else if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{else}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Extracted Logic Pattern
For complex conditions, use getters:
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class UserCard extends Component {
@cached
get isActive() {
return this.args.user.status === 'active' && this.args.user.lastLoginDays < 30;
}
@cached
get showActions() {
return this.args.canEdit && !this.args.user.locked && this.isActive;
}
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
{{#if this.isActive}}
<span class="status active">Active</span>
{{else}}
<span class="status inactive">Inactive</span>
{{/if}}
{{#if this.showActions}}
<div class="actions">
<button>Edit</button>
<button>Delete</button>
</div>
{{/if}}
</div>
</template>
}
```
## Conditional Lists
Use `{{#if}}` to guard `{{#each}}` and avoid rendering empty states:
```glimmer-js
// app/components/task-list.gjs
import Component from '@glimmer/component';
class TaskList extends Component {
get hasTasks() {
return this.args.tasks?.length > 0;
}
<template>
{{#if this.hasTasks}}
<ul class="task-list">
{{#each @tasks as |task|}}
<li>
{{task.title}}
{{#if task.completed}}
<span class="done">✓</span>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<p class="empty-state">No tasks yet</p>
{{/if}}
</template>
}
```
## Avoid Nested Conditionals
**Bad:**
```glimmer-js
{{#if @user}}
{{#if @user.isPremium}}
{{#if @user.hasAccess}}
<PremiumContent />
{{/if}}
{{/if}}
{{/if}}
```
**Good:**
```glimmer-js
// app/components/content-gate.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class ContentGate extends Component {
@cached
get canViewPremium() {
return this.args.user?.isPremium && this.args.user?.hasAccess;
}
<template>
{{#if this.canViewPremium}}
<PremiumContent />
{{else}}
<UpgradeCTA />
{{/if}}
</template>
}
```
## Component Switching Pattern
Use conditional rendering for component selection:
```glimmer-js
// app/components/media-viewer.gjs
import Component from '@glimmer/component';
import ImageViewer from './image-viewer';
import VideoPlayer from './video-player';
import AudioPlayer from './audio-player';
import { cached } from '@glimmer/tracking';
class MediaViewer extends Component {
@cached
get mediaType() {
return this.args.media?.type;
}
<template>
{{#if (eq this.mediaType "image")}}
<ImageViewer @src={{@media.url}} />
{{else if (eq this.mediaType "video")}}
<VideoPlayer @src={{@media.url}} />
{{else if (eq this.mediaType "audio")}}
<AudioPlayer @src={{@media.url}} />
{{else}}
<p>Unsupported media type</p>
{{/if}}
</template>
}
```
## Loading States
Pattern for async data with loading/error states:
```glimmer-js
// app/components/data-display.gjs
import Component from '@glimmer/component';
import { Resource } from 'ember-resources';
import { resource } from 'ember-resources';
class DataResource extends Resource {
@tracked data = null;
@tracked isLoading = true;
@tracked error = null;
modify(positional, named) {
this.fetchData(named.url);
}
async fetchData(url) {
this.isLoading = true;
this.error = null;
try {
const response = await fetch(url);
this.data = await response.json();
} catch (e) {
this.error = e.message;
} finally {
this.isLoading = false;
}
}
}
class DataDisplay extends Component {
@resource data = DataResource.from(() => ({
url: this.args.url,
}));
<template>
{{#if this.data.isLoading}}
<div class="loading">Loading...</div>
{{else if this.data.error}}
<div class="error">Error: {{this.data.error}}</div>
{{else}}
<div class="content">
{{this.data.data}}
</div>
{{/if}}
</template>
}
```
## Performance Impact
- **Chained if/else**: 40-60% faster than multiple independent {{#if}} blocks
- **Extracted getters**: ~20% faster for complex conditions (cached)
- **Component switching**: Same performance as {{#if}} but better code organization
## When to Use
- **{{#if}}/{{#else}}**: For simple true/false conditions
- **Extracted getters**: For complex or reused conditions
- **Component switching**: For different component types based on state
- **Guard clauses**: To avoid rendering large subtrees when not needed
## References
- [Ember Guides - Conditionals](https://guides.emberjs.com/release/components/conditional-content/)
- [Glimmer VM Performance](https://github.com/glimmerjs/glimmer-vm)
- [@cached decorator](https://api.emberjs.com/ember/release/functions/@glimmer%2Ftracking/cached)

View File

@@ -0,0 +1,91 @@
---
title: Use {{#each}} with @key for Lists
impact: MEDIUM
impactDescription: 50-100% faster list updates
tags: templates, each, performance, rendering
---
## Use {{#each}} with @key for Lists
Use the `key=` parameter with `{{#each}}` when objects are recreated between renders (e.g., via `.map()` or fresh API data). The default behavior uses object identity (`@identity`), which works when object references are stable.
**Incorrect (no key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**Correct (with key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users key="id" as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**For arrays of primitives (strings, numbers):**
`@identity` is the default, so you rarely need to specify it explicitly. It compares items by value for primitives.
```glimmer-js
// app/components/tag-list.gjs
<template>
{{! @identity is implicit, no need to write it }}
{{#each this.tags as |tag|}}
<span class="tag">{{tag}}</span>
{{/each}}
</template>
```
**For complex scenarios with @index:**
```glimmer-js
// app/components/item-list.gjs
<template>
{{#each this.items key="@index" as |item index|}}
<div data-index={{index}}>
{{item.name}}
</div>
{{/each}}
</template>
```
Using proper keys allows Ember's rendering engine to efficiently update, reorder, and remove items without re-rendering the entire list.
**When to use `key=`:**
- Objects recreated between renders (`.map()`, generators, fresh API responses) → use `key="id"` or similar
- High-frequency updates (animations, real-time data) → always specify a key
- Stable object references (Apollo cache, Ember Data) → default `@identity` is fine
- Items never reorder → `key="@index"` is acceptable
**Performance comparison (dbmon benchmark, 40 rows at 60fps):**
- Without key (objects recreated): Destroys/recreates DOM every frame
- With `key="data.db.id"`: DOM reuse, **2x FPS improvement**
### References:
- [Ember API: each helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each)
- [Ember template lint: equire-each-key](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/require-each-key.md)
- [Example PR showing the fps improvement on updated lists](https://github.com/universal-ember/table/pull/68)

View File

@@ -0,0 +1,148 @@
---
title: Use {{fn}} for Partial Application Only
impact: LOW-MEDIUM
impactDescription: Clearer code, avoid unnecessary wrapping
tags: helpers, templates, fn, partial-application
---
## Use {{fn}} for Partial Application Only
The `{{fn}}` helper is used for partial application (binding arguments), similar to JavaScript's `.bind()`. Only use it when you need to pre-bind arguments to a function. Don't use it to simply pass a function reference.
**Incorrect (unnecessary use of {{fn}}):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Wrong - no arguments being bound}}
<input {{on "input" (fn this.handleSearch)}} />
</template>
}
```
**Correct (direct function reference):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Correct - pass function directly}}
<input {{on "input" this.handleSearch}} />
</template>
}
```
**When to Use {{fn}} - Partial Application:**
Use `{{fn}}` when you need to pre-bind arguments to a function, similar to JavaScript's `.bind()`:
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class UserList extends Component {
@action
deleteUser(userId, event) {
console.log('Deleting user:', userId);
this.args.onDelete(userId);
}
<template>
<ul>
{{#each @users as |user|}}
<li>
{{user.name}}
{{! Correct - binding user.id as first argument}}
<button {{on "click" (fn this.deleteUser user.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
}
```
**Multiple Arguments:**
```glimmer-js
// app/components/data-grid.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class DataGrid extends Component {
@action
updateCell(rowId, columnKey, event) {
const newValue = event.target.value;
this.args.onUpdate(rowId, columnKey, newValue);
}
<template>
{{#each @rows as |row|}}
{{#each @columns as |column|}}
<input
value={{get row column.key}}
{{! Pre-binding rowId and columnKey}}
{{on "input" (fn this.updateCell row.id column.key)}}
/>
{{/each}}
{{/each}}
</template>
}
```
**Think of {{fn}} like .bind():**
```javascript
// JavaScript comparison
const boundFn = this.deleteUser.bind(this, userId); // .bind() pre-binds args
// Template equivalent: {{fn this.deleteUser userId}}
// Direct reference
const directFn = this.handleSearch; // No pre-binding
// Template equivalent: {{this.handleSearch}}
```
**Common Patterns:**
```javascript
// ❌ Wrong - no partial application
<button {{on "click" (fn this.save)}}>Save</button>
// ✅ Correct - direct reference
<button {{on "click" this.save}}>Save</button>
// ✅ Correct - partial application with argument
<button {{on "click" (fn this.save "draft")}}>Save Draft</button>
// ❌ Wrong - no partial application
<input {{on "input" (fn this.handleInput)}} />
// ✅ Correct - direct reference
<input {{on "input" this.handleInput}} />
// ✅ Correct - partial application with field name
<input {{on "input" (fn this.updateField "email")}} />
```
Only use `{{fn}}` when you're binding arguments. For simple function references, pass them directly.
Reference: [Ember Templates - fn Helper](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_passing-arguments-to-functions)

View File

@@ -0,0 +1,116 @@
---
title: Import Helpers Directly in Templates
impact: MEDIUM
impactDescription: Better tree-shaking and clarity
tags: helpers, imports, templates, gjs
---
## Import Helpers Directly in Templates
Import helpers directly in gjs/gts files for better tree-shaking, clearer dependencies, and improved type safety.
**Incorrect (global helper resolution):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{format-date @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Correct (explicit helper imports):**
```glimmer-js
// app/components/user-profile.gjs
import { capitalize } from 'ember-string-helpers';
import { formatDate } from 'ember-intl';
import { pluralize } from 'ember-inflector';
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{formatDate @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Built-in and library helpers:**
```glimmer-js
// app/components/conditional-content.gjs
import { fn, hash } from '@ember/helper'; // Actually built-in to Ember
import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon
<template>
<div class="content">
{{#if (eq @status "active")}}
<span class="badge">Active</span>
{{/if}}
{{#if (not @isLoading)}}
<button {{on "click" (fn @onSave (hash id=@id data=@data))}}>
Save
</button>
{{/if}}
</div>
</template>
```
**Custom helper with imports:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, { currency = 'USD' } = {}) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price-display.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<div class="price">
{{formatCurrency @amount currency="EUR"}}
</div>
</template>
```
**Type-safe helpers with TypeScript:**
```glimmer-ts
// app/components/typed-component.gts
import { fn } from '@ember/helper';
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
items: Array<{ id: string; name: string }>;
onSelect: (id: string) => void;
};
}
const TypedComponent: TOC<Signature> = <template>
<ul>
{{#each @items as |item|}}
<li {{on "click" (fn @onSelect item.id)}}>
{{item.name}}
</li>
{{/each}}
</ul>
</template>;
export default TypedComponent;
```
Explicit helper imports enable better tree-shaking, make dependencies clear, and improve IDE support with proper type checking.
Reference: [Template Imports](https://github.com/ember-template-imports/ember-template-imports)

View File

@@ -0,0 +1,79 @@
---
title: Use {{#let}} to Avoid Recomputation
impact: MEDIUM
impactDescription: 30-50% reduction in duplicate work
tags: templates, helpers, performance, optimization
---
## Use {{#let}} to Avoid Recomputation
Use `{{#let}}` to compute expensive values once and reuse them in the template instead of calling getters or helpers multiple times.
**Incorrect (recomputes on every reference):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
</template>
```
**Correct (compute once, reuse):**
```glimmer-js
// app/components/user-card.gjs
<template>
{{#let (and this.user.isActive (not this.user.isDeleted)) as |isEditable|}}
<div class="user-card">
{{#if isEditable}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
{{/let}}
</template>
```
**Multiple values:**
```glimmer-js
// app/components/checkout.gjs
<template>
{{#let
(this.calculateTotal this.items) (this.formatCurrency this.total) (this.hasDiscount this.user)
as |total formattedTotal showDiscount|
}}
<div class="checkout">
<p>Total: {{formattedTotal}}</p>
{{#if showDiscount}}
<p>Original: {{total}}</p>
<p>Discount Applied!</p>
{{/if}}
</div>
{{/let}}
</template>
```
`{{#let}}` computes values once and caches them for the block scope, reducing redundant calculations.

View File

@@ -0,0 +1,220 @@
---
title: Template-Only Components with In-Scope Functions
impact: MEDIUM
impactDescription: Clean, performant patterns for template-only components
tags: templates, components, functions, performance
---
## Template-Only Components with In-Scope Functions
For template-only components (components without a class and `this`), use in-scope functions to keep logic close to the template while avoiding unnecessary caching overhead.
**Incorrect (using class-based component for simple logic):**
```glimmer-js
// app/components/product-card.gjs
import Component from '@glimmer/component';
export class ProductCard extends Component {
// Unnecessary class and overhead for simple formatting
formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
<div class="price">{{this.formatPrice @product.price}}</div>
</div>
</template>
}
```
**Correct (template-only component with in-scope functions):**
```glimmer-js
// app/components/product-card.gjs
function formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
function calculateDiscount(price, discountPercent) {
return price * (1 - discountPercent / 100);
}
function isOnSale(product) {
return product.discountPercent > 0;
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
{{#if (isOnSale @product)}}
<div class="price">
<span class="original">{{formatPrice @product.price}}</span>
<span class="sale">
{{formatPrice (calculateDiscount @product.price @product.discountPercent)}}
</span>
</div>
{{else}}
<div class="price">{{formatPrice @product.price}}</div>
{{/if}}
<p>{{@product.description}}</p>
</div>
</template>
```
**When to use class-based vs template-only:**
```glimmer-js
// Use class-based when:
// - You need @cached for expensive computations accessed multiple times
// - You have tracked state
// - You need lifecycle hooks or services
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class ProductList extends Component {
@cached
get sortedProducts() {
// Expensive sort, accessed in template multiple times
return [...this.args.products].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get filteredProducts() {
// Depends on sortedProducts - benefits from caching
return this.sortedProducts.filter((p) => p.category === this.args.selectedCategory);
}
<template>
{{#each this.filteredProducts as |product|}}
<div>{{product.name}}</div>
{{/each}}
</template>
}
```
```glimmer-js
// Use template-only when:
// - Simple transformations
// - Functions accessed once
// - No state or services needed
function formatDate(date) {
return new Date(date).toLocaleDateString();
}
<template>
<div class="timestamp">
Last updated:
{{formatDate @lastUpdate}}
</div>
</template>
```
**Combining in-scope functions for readability:**
```glimmer-js
// app/components/user-badge.gjs
function getInitials(name) {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase();
}
function getBadgeColor(status) {
const colors = {
active: 'green',
pending: 'yellow',
inactive: 'gray',
};
return colors[status] || 'gray';
}
<template>
<div class="user-badge" style="background-color: {{getBadgeColor @user.status}}">
<span class="initials">{{getInitials @user.name}}</span>
<span class="name">{{@user.name}}</span>
</div>
</template>
```
**Anti-pattern - Complex nested calls:**
```glimmer-js
// ❌ Hard to read, lots of nesting
<template>
<div>
{{formatCurrency (multiply (add @basePrice @taxAmount) @quantity)}}
</div>
</template>
// ✅ Better - use intermediate function
function calculateTotal(basePrice, taxAmount, quantity) {
return (basePrice + taxAmount) * quantity;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
<template>
<div>
{{formatCurrency (calculateTotal @basePrice @taxAmount @quantity)}}
</div>
</template>
```
**Key differences from class-based components:**
| Aspect | Template-Only | Class-Based |
| ---------------- | ------------------------ | ------------------------ |
| `this` context | ❌ No `this` | ✅ Has `this` |
| Function caching | ❌ Recreated each render | ✅ `@cached` available |
| Services | ❌ Cannot inject | ✅ `@service` decorator |
| Tracked state | ❌ No instance state | ✅ `@tracked` properties |
| Best for | Simple, stateless | Complex, stateful |
**Best practices:**
1. **Keep functions simple** - If computation is complex, consider a class with `@cached`
2. **One responsibility per function** - Makes them reusable and testable
3. **Minimize nesting** - Use intermediate functions for readability
4. **No side effects** - Functions should be pure transformations
5. **Export for testing** - Export functions so they can be tested independently
```glimmer-js
// app/components/stats-display.gjs
export function average(numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}
export function round(number, decimals = 2) {
return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
<template>
<div class="stats">
Average:
{{round (average @scores)}}
</div>
</template>
```
Reference: [Template-only Components](https://guides.emberjs.com/release/components/component-types/), [Component Authoring Best Practices](https://guides.emberjs.com/release/components/conditional-content/)

View File

@@ -0,0 +1,329 @@
---
title: Provide DOM-Abstracted Test Utilities for Library Components
impact: MEDIUM
impactDescription: Stabilizes consumer tests against internal DOM refactors
tags: testing, test-support, libraries, dom-abstraction, maintainability
---
## Provide DOM-Abstracted Test Utilities for Library Components
**Impact: Medium** - Critical for library maintainability and consumer testing experience, especially important for team-based projects
## Problem
When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered **private** unless the author of the tests is the **owner** of the code being tested.
Without abstracted test utilities:
- Component refactoring breaks consumer tests
- Tests are tightly coupled to implementation details
- Teams waste time updating tests when internals change
- Testing becomes fragile and maintenance-heavy
## Solution
**Library authors should provide test utilities that fully abstract the DOM.** These utilities expose a public API for testing that remains stable even when internal implementation changes.
**Incorrect (exposing DOM to consumers):**
```glimmer-js
// my-library/src/components/data-grid.gjs
export class DataGrid extends Component {
<template>
<div class="data-grid">
<div class="data-grid__header">
<button class="sort-button" data-column="name">Name</button>
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - tightly coupled to DOM
import { render, click } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} /></template>);
// Fragile: breaks if class names or structure change
await click('.data-grid__header .sort-button[data-column="name"]');
assert.dom('.data-grid__row:first-child').hasText('Alice');
});
```
**Problems:**
- Consumer knows about `.data-grid__header`, `.sort-button`, `[data-column]`
- Refactoring component structure breaks consumer tests
- No clear public API for testing
**Correct (providing DOM-abstracted test utilities):**
```glimmer-js
// my-library/src/test-support/data-grid.js
import { click, findAll } from '@ember/test-helpers';
/**
* Test utility for DataGrid component
* Provides stable API regardless of internal DOM structure
*/
export class DataGridTestHelper {
constructor(containerElement) {
this.container = containerElement;
}
/**
* Sort by column name
* @param {string} columnName - Column to sort by
*/
async sortBy(columnName) {
// Implementation detail hidden from consumer
const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
if (!button) {
throw new Error(`Column "${columnName}" not found`);
}
await click(button);
}
/**
* Get all row data
* @returns {Array<string>} Row text content
*/
getRows() {
return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
}
/**
* Get row by index
* @param {number} index - Zero-based row index
* @returns {string} Row text content
*/
getRow(index) {
const rows = this.getRows();
return rows[index];
}
}
// Factory function for easier usage
export function getDataGrid(container = document) {
const gridElement = container.querySelector('[data-test-data-grid]');
if (!gridElement) {
throw new Error('DataGrid component not found');
}
return new DataGridTestHelper(gridElement);
}
```
```glimmer-js
// my-library/src/components/data-grid.gjs
// Component updated with test hooks (data-test-*)
export class DataGrid extends Component {
<template>
<div data-test-data-grid class="data-grid">
<div class="data-grid__header">
{{#each @columns as |column|}}
<button data-test-sort={{column.name}}>
{{column.label}}
</button>
{{/each}}
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div data-test-row class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - abstracted from DOM
import { render } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
import { getDataGrid } from 'my-library/test-support';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);
const grid = getDataGrid();
// Clean API: no DOM knowledge required
await grid.sortBy('name');
assert.strictEqual(grid.getRow(0), 'Alice');
assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
});
```
**Benefits:**
- Component internals can change without breaking consumer tests
- Clear, documented testing API
- Consumer tests are declarative and readable
- Library maintains API stability contract
## When This Matters Most
### Team-Based Projects (Critical)
On projects with teams, DOM abstraction prevents:
- Merge conflicts from test changes
- Cross-team coordination overhead
- Broken tests from uncoordinated refactoring
- Knowledge silos about component internals
### Solo Projects (Less Critical)
For solo projects, the benefit is smaller but still valuable:
- Easier refactoring without test maintenance
- Better separation of concerns
- Professional API design practice
## Best Practices
### 1. Use `data-test-*` Attributes
```glimmer-js
// Stable test hooks that won't conflict with styling
<button data-test-submit>Submit</button>
<div data-test-error-message>{{@errorMessage}}</div>
```
### 2. Document the Test API
```javascript
/**
* @class FormTestHelper
* @description Test utility for Form component
*
* @example
* const form = getForm();
* await form.fillIn('email', 'user@example.com');
* await form.submit();
* assert.strictEqual(form.getError(), 'Invalid email');
*/
```
### 3. Provide Semantic Methods
```javascript
// ✅ Semantic and declarative
await modal.close();
await form.fillIn('email', 'test@example.com');
assert.true(dropdown.isOpen());
// ❌ Exposes implementation
await click('.modal-close-button');
await fillIn('.form-field[name="email"]', 'test@example.com');
assert.dom('.dropdown.is-open').exists();
```
### 4. Handle Edge Cases
```javascript
export class FormTestHelper {
async fillIn(fieldName, value) {
const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
if (!field) {
throw new Error(
`Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
);
}
await fillIn(field, value);
}
getFieldNames() {
return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
(el) => el.dataset.testField,
);
}
}
```
## Example: Complete Test Utility
```javascript
// addon/test-support/modal.js
import { click, find, waitUntil } from '@ember/test-helpers';
export class ModalTestHelper {
constructor(container = document) {
this.container = container;
}
get element() {
return find('[data-test-modal]', this.container);
}
isOpen() {
return this.element !== null;
}
async waitForOpen() {
await waitUntil(() => this.isOpen(), { timeout: 1000 });
}
async waitForClose() {
await waitUntil(() => !this.isOpen(), { timeout: 1000 });
}
getTitle() {
const titleEl = find('[data-test-modal-title]', this.element);
return titleEl ? titleEl.textContent.trim() : null;
}
getBody() {
const bodyEl = find('[data-test-modal-body]', this.element);
return bodyEl ? bodyEl.textContent.trim() : null;
}
async close() {
if (!this.isOpen()) {
throw new Error('Cannot close modal: modal is not open');
}
await click('[data-test-modal-close]', this.element);
}
async clickButton(buttonText) {
const buttons = findAll('[data-test-modal-button]', this.element);
const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
if (!button) {
const available = buttons.map((b) => b.textContent.trim()).join(', ');
throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
}
await click(button);
}
}
export function getModal(container) {
return new ModalTestHelper(container);
}
```
## Performance Impact
**Before:** ~30-50% of test maintenance time spent updating selectors
**After:** Minimal test maintenance when refactoring components
## Related Patterns
- **component-avoid-classes-in-examples.md** - Avoid exposing implementation details
- **testing-modern-patterns.md** - Modern testing approaches
- **testing-render-patterns.md** - Component testing patterns
## References
- [Testing Best Practices - ember-learn](https://guides.emberjs.com/release/testing/)
- [ember-test-selectors](https://github.com/mainmatter/ember-test-selectors) - Addon for stripping test selectors from production
- [Page Objects Pattern](https://martinfowler.com/bliki/PageObject.html) - Related testing abstraction pattern

View File

@@ -0,0 +1,340 @@
---
title: Use Modern Testing Patterns
impact: HIGH
impactDescription: Better test coverage and maintainability
tags: testing, qunit, test-helpers, integration-tests
---
## Use Modern Testing Patterns
Use modern Ember testing patterns with `@ember/test-helpers` and `qunit-dom` for better test coverage and maintainability.
**Incorrect (old testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><UserCard /></template>);
// Using find() instead of qunit-dom
assert.ok(find('.user-card'));
});
});
```
**Correct (modern testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { setupIntl } from 'ember-intl/test-support';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
test('it renders user information', async function (assert) {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatarUrl: '/avatar.jpg',
};
await render(<template><UserCard @user={{user}} /></template>);
// qunit-dom assertions
assert.dom('[data-test-user-name]').hasText('John Doe');
assert.dom('[data-test-user-email]').hasText('john@example.com');
assert
.dom('[data-test-user-avatar]')
.hasAttribute('src', '/avatar.jpg')
.hasAttribute('alt', 'John Doe');
});
test('it handles edit action', async function (assert) {
assert.expect(1);
const user = { name: 'John Doe', email: 'john@example.com' };
const handleEdit = (editedUser) => {
assert.deepEqual(editedUser, user, 'Edit handler called with user');
};
await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);
await click('[data-test-edit-button]');
});
});
```
**Component testing with reactive state:**
```glimmer-ts
// tests/integration/components/search-box-test.ts
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { trackedObject } from '@ember/reactive/collections';
import SearchBox from 'my-app/components/search-box';
module('Integration | Component | search-box', function (hooks) {
setupRenderingTest(hooks);
test('it performs search', async function (assert) {
// Use trackedObject for reactive state in tests
const state = trackedObject({
results: [] as string[],
});
const handleSearch = (query: string) => {
state.results = [`Result for ${query}`];
};
await render(
<template>
<SearchBox @onSearch={{handleSearch}} />
<ul data-test-results>
{{#each state.results as |result|}}
<li>{{result}}</li>
{{/each}}
</ul>
</template>,
);
await fillIn('[data-test-search-input]', 'ember');
// State updates reactively - no waitFor needed when using test-waiters
assert.dom('[data-test-results] li').hasText('Result for ember');
});
});
```
**Testing with ember-concurrency tasks:**
```glimmer-js
// app/components/async-button.js
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
export default class AsyncButtonComponent extends Component {
@task
*saveTask() {
yield this.args.onSave();
}
<template>
<button
type="button"
disabled={{this.saveTask.isRunning}}
{{on "click" (perform this.saveTask)}}
data-test-button
>
{{#if this.saveTask.isRunning}}
<span data-test-loading-spinner>Saving...</span>
{{else}}
{{yield}}
{{/if}}
</button>
</template>
}
```
```glimmer-js
// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import AsyncButton from 'my-app/components/async-button';
module('Integration | Component | async-button', function (hooks) {
setupRenderingTest(hooks);
test('it shows loading state during task execution', async function (assert) {
let resolveTask;
const onSave = () => {
return new Promise((resolve) => {
resolveTask = resolve;
});
};
await render(
<template>
<AsyncButton @onSave={{onSave}}>
Save
</AsyncButton>
</template>,
);
// Trigger the task
await click('[data-test-button]');
// ember-concurrency automatically registers test waiters
// The button will be disabled while the task runs
assert.dom('[data-test-button]').hasAttribute('disabled');
assert.dom('[data-test-loading-spinner]').hasText('Saving...');
// Resolve the task
resolveTask();
// No need to call settled() - ember-concurrency's test waiters handle this
assert.dom('[data-test-button]').doesNotHaveAttribute('disabled');
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-button]').hasText('Save');
});
});
```
**When to use test-waiters with ember-concurrency:**
- **ember-concurrency auto-registers test waiters** - You don't need to manually register test waiters for ember-concurrency tasks. The library automatically waits for tasks to complete before test helpers like `click()`, `fillIn()`, etc. resolve.
- **You still need test-waiters when:**
- Using raw Promises outside of ember-concurrency tasks
- Working with third-party async operations that don't integrate with Ember's test waiter system
- Creating custom async behavior that needs to pause test execution
- **You DON'T need additional test-waiters when:**
- Using ember-concurrency tasks (already handled)
- Using Ember Data operations (already handled)
- Using `settled()` from `@ember/test-helpers` (already coordinates with test waiters)
- **Note**: `waitFor()` and `waitUntil()` from `@ember/test-helpers` are code smells - if you need them, it indicates missing test-waiters in your code. Instrument your async operations with test-waiters instead.
**Route testing with MSW (Mock Service Worker):**
```javascript
// tests/acceptance/posts-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { http, HttpResponse } from 'msw';
import { setupMSW } from 'my-app/tests/helpers/msw';
module('Acceptance | posts', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('visiting /posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.strictEqual(currentURL(), '/posts');
assert.dom('[data-test-post-item]').exists({ count: 3 });
});
test('clicking a post navigates to detail', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } }],
});
}),
http.get('/api/posts/test-post', () => {
return HttpResponse.json({
data: { id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } },
});
}),
);
await visit('/posts');
await click('[data-test-post-item]:first-child');
assert.strictEqual(currentURL(), '/posts/test-post');
assert.dom('[data-test-post-title]').hasText('Test Post');
});
});
```
**Note:** Use MSW (Mock Service Worker) for API mocking instead of Mirage. MSW provides better conventions and doesn't lead developers astray. See `testing-msw-setup.md` for detailed setup instructions.
**Accessibility testing:**
```glimmer-js
// tests/integration/components/modal-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import Modal from 'my-app/components/modal';
module('Integration | Component | modal', function (hooks) {
setupRenderingTest(hooks);
test('it passes accessibility audit', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}} @title="Test Modal">
<p>Modal content</p>
</Modal>
</template>,
);
await a11yAudit();
assert.ok(true, 'no a11y violations');
});
test('it traps focus', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}}>
<button data-test-first>First</button>
<button data-test-last>Last</button>
</Modal>
</template>,
);
assert.dom('[data-test-first]').isFocused();
// Tab should stay within modal
await click('[data-test-last]');
assert.dom('[data-test-last]').isFocused();
});
});
```
**Testing with data-test attributes:**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
class UserProfile extends Component {
<template>
<div class="user-profile" data-test-user-profile>
<img src={{@user.avatar}} alt={{@user.name}} data-test-avatar />
<h2 data-test-name>{{@user.name}}</h2>
<p data-test-email>{{@user.email}}</p>
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}} data-test-edit-button>
Edit
</button>
{{/if}}
</div>
</template>
}
```
Modern testing patterns with `@ember/test-helpers`, `qunit-dom`, and data-test attributes provide better test reliability, readability, and maintainability.
Reference: [Ember Testing](https://guides.emberjs.com/release/testing/)

View File

@@ -0,0 +1,541 @@
---
title: MSW (Mock Service Worker) Setup for Testing
impact: HIGH
impactDescription: Proper API mocking without ORM complexity
tags: testing, msw, api-mocking, mock-service-worker
---
## MSW (Mock Service Worker) Setup for Testing
Use MSW (Mock Service Worker) for API mocking in tests. MSW provides a cleaner approach than Mirage by intercepting requests at the network level without introducing unnecessary ORM patterns or abstractions.
**Incorrect (using Mirage with ORM complexity):**
```javascript
// mirage/config.js
export default function () {
this.namespace = '/api';
// Complex schema and factories
this.get('/users', (schema) => {
return schema.users.all();
});
// Need to maintain schema, factories, serializers
this.post('/users', (schema, request) => {
let attrs = JSON.parse(request.requestBody);
return schema.users.create(attrs);
});
}
```
**Correct (using MSW with simple network mocking):**
```javascript
// tests/helpers/msw.js
import { http, HttpResponse } from 'msw';
// Simple request/response mocking
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const user = await request.json();
return HttpResponse.json({ id: 3, ...user }, { status: 201 });
}),
];
```
**Why MSW over Mirage:**
- **Better conventions** - Mock at the network level, not with an ORM
- **Simpler mental model** - Define request handlers, return responses
- **Doesn't lead developers astray** - No schema migrations or factories to maintain
- **Works everywhere** - Same mocks work in tests, Storybook, and development
- **More realistic** - Actually intercepts fetch/XMLHttpRequest
Reference: [Ember.js Community Discussion on MSW](https://discuss.emberjs.com/t/my-cookbook-for-various-emberjs-things/19679)
### Installation
```bash
npm install --save-dev msw
```
### Setup Test Helper
Create a test helper to set up MSW in your tests:
```javascript
// tests/helpers/msw.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Define default handlers that apply to all tests
const defaultHandlers = [
// Add default handlers here if needed
];
export function setupMSW(hooks, handlers = []) {
const server = setupServer(...defaultHandlers, ...handlers);
hooks.beforeEach(function () {
server.listen({ onUnhandledRequest: 'warn' });
});
hooks.afterEach(function () {
server.resetHandlers();
});
hooks.after(function () {
server.close();
});
return { server };
}
// Re-export for convenience
export { http, HttpResponse };
```
### Basic Usage in Tests
```javascript
// tests/acceptance/users-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
module('Acceptance | users', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('displays list of users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [
{
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
{
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
],
});
}),
);
await visit('/users');
assert.strictEqual(currentURL(), '/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles server errors gracefully', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ errors: [{ title: 'Server Error' }] }, { status: 500 });
}),
);
await visit('/users');
assert.dom('[data-test-error-message]').exists();
assert.dom('[data-test-error-message]').containsText('Server Error');
});
});
```
### Mocking POST/PUT/DELETE Requests
```javascript
import { visit, click, fillIn } from '@ember/test-helpers';
test('creates a new user', async function (assert) {
let capturedRequest = null;
server.use(
http.post('/api/users', async ({ request }) => {
capturedRequest = await request.json();
return HttpResponse.json(
{
data: {
id: '3',
type: 'user',
attributes: capturedRequest.data.attributes,
},
},
{ status: 201 },
);
}),
);
await visit('/users/new');
await fillIn('[data-test-name-input]', 'Charlie');
await fillIn('[data-test-email-input]', 'charlie@example.com');
await click('[data-test-submit-button]');
assert.strictEqual(currentURL(), '/users/3');
assert.deepEqual(capturedRequest.data.attributes, {
name: 'Charlie',
email: 'charlie@example.com',
});
});
test('updates an existing user', async function (assert) {
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
});
}),
http.patch('/api/users/1', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: body.data.attributes,
},
});
}),
);
await visit('/users/1/edit');
await fillIn('[data-test-name-input]', 'Alice Updated');
await click('[data-test-submit-button]');
assert.dom('[data-test-user-name]').hasText('Alice Updated');
});
test('deletes a user', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
http.delete('/api/users/1', () => {
return new HttpResponse(null, { status: 204 });
}),
);
await visit('/users');
await click('[data-test-delete-button]');
assert.dom('[data-test-user-item]').doesNotExist();
});
```
### Query Parameters and Dynamic Routes
```javascript
test('filters users by query parameter', async function (assert) {
server.use(
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const searchQuery = url.searchParams.get('filter[name]');
const users = [
{ id: '1', type: 'user', attributes: { name: 'Alice' } },
{ id: '2', type: 'user', attributes: { name: 'Bob' } },
];
const filtered = searchQuery
? users.filter((u) => u.attributes.name.includes(searchQuery))
: users;
return HttpResponse.json({ data: filtered });
}),
);
await visit('/users?filter[name]=Alice');
assert.dom('[data-test-user-item]').exists({ count: 1 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles dynamic route segments', async function (assert) {
server.use(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
data: {
id: params.id,
type: 'user',
attributes: { name: `User ${params.id}` },
},
});
}),
);
await visit('/users/42');
assert.dom('[data-test-user-name]').hasText('User 42');
});
```
### Network Delays and Race Conditions
```javascript
test('handles slow network responses', async function (assert) {
server.use(
http.get('/api/users', async () => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
const visitPromise = visit('/users');
// Loading state should be visible
assert.dom('[data-test-loading-spinner]').exists();
await visitPromise;
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-user-item]').exists();
});
```
### Shared Handlers with Reusable Fixtures
```javascript
// tests/helpers/msw-handlers.js
import { http, HttpResponse } from 'msw';
export const userHandlers = {
list: (users = []) => {
return http.get('/api/users', () => {
return HttpResponse.json({ data: users });
});
},
get: (user) => {
return http.get(`/api/users/${user.id}`, () => {
return HttpResponse.json({ data: user });
});
},
create: (attributes) => {
return http.post('/api/users', () => {
return HttpResponse.json(
{
data: {
id: String(Math.random()),
type: 'user',
attributes,
},
},
{ status: 201 },
);
});
},
};
// Common fixtures
export const fixtures = {
users: {
alice: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
bob: {
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
},
};
```
```javascript
// tests/acceptance/users-test.js
import { userHandlers, fixtures } from 'my-app/tests/helpers/msw-handlers';
test('displays list of users', async function (assert) {
server.use(userHandlers.list([fixtures.users.alice, fixtures.users.bob]));
await visit('/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
});
```
### Integration Test Setup
MSW works in integration tests too:
```javascript
// tests/integration/components/user-list-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, waitFor } from '@ember/test-helpers';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
import UserList from 'my-app/components/user-list';
module('Integration | Component | user-list', function (hooks) {
setupRenderingTest(hooks);
const { server } = setupMSW(hooks);
test('fetches and displays users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
await render(
<template>
<UserList />
</template>,
);
// Wait for async data to load
await waitFor('[data-test-user-item]');
assert.dom('[data-test-user-item]').exists();
assert.dom('[data-test-user-name]').hasText('Alice');
});
});
```
### Best Practices
1. **Define handlers per test** - Use `server.use()` in individual tests rather than global handlers
2. **Reset between tests** - The helper automatically resets handlers after each test
3. **Use JSON:API format** - Keep responses consistent with your API format
4. **Test error states** - Mock various HTTP error codes (400, 401, 403, 404, 500)
5. **Capture requests** - Use the request object to verify what your app sent
6. **Use fixtures** - Create reusable test data to keep tests DRY
7. **Simulate delays** - Test loading states with artificial delays
8. **Type-safe responses** - In TypeScript, type your response payloads
### Common Patterns
**Default handlers for all tests:**
```javascript
// tests/helpers/msw.js
const defaultHandlers = [
// Always return current user
http.get('/api/current-user', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Test User', role: 'admin' },
},
});
}),
];
```
**One-time handlers (don't persist):**
```javascript
// MSW handlers persist until resetHandlers() is called
// The test helper automatically resets after each test
// For a one-time handler within a test, manually reset:
test('one-time response', async function (assert) {
server.use(
http.get('/api/special', () => {
return HttpResponse.json({ data: 'special' });
}),
);
// First request gets mocked response
await visit('/special');
assert.dom('[data-test-data]').hasText('special');
// Reset to remove this handler
server.resetHandlers();
// Subsequent requests will use default handlers or be unhandled
});
```
**Conditional responses:**
```javascript
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password') {
return HttpResponse.json({
data: { token: 'abc123' },
});
}
return HttpResponse.json({ errors: [{ title: 'Invalid credentials' }] }, { status: 401 });
});
```
### Migration from Mirage
If migrating from Mirage:
1. Remove `ember-cli-mirage` dependency
2. Delete `mirage/` directory (models, factories, scenarios)
3. Install MSW: `npm install --save-dev msw`
4. Create the MSW test helper (see above)
5. Replace `setupMirage(hooks)` with `setupMSW(hooks)`
6. Convert Mirage handlers:
- `this.server.get()``http.get()`
- `this.server.create()` → Return inline JSON
- `this.server.createList()` → Return array of JSON objects
**Before (Mirage):**
```javascript
test('lists posts', async function (assert) {
this.server.createList('post', 3);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
```
**After (MSW):**
```javascript
test('lists posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
```
Reference: [MSW Documentation](https://mswjs.io/docs/)

View File

@@ -0,0 +1,323 @@
---
title: Use qunit-dom for Better Test Assertions
impact: MEDIUM
impactDescription: More readable and maintainable tests
tags: testing, qunit-dom, assertions, best-practices
---
## Use qunit-dom for Better Test Assertions
Use `qunit-dom` for DOM assertions in tests. It provides expressive, chainable assertions that make tests more readable and provide better error messages than raw QUnit assertions.
**Why qunit-dom:**
- More expressive and readable test assertions
- Better error messages when tests fail
- Type-safe with TypeScript
- Reduces boilerplate in DOM testing
### Basic DOM Assertions
**Incorrect (verbose QUnit assertions):**
```javascript
// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
module('Integration | Component | greeting', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><Greeting @name="World" /></template>);
const element = this.element.querySelector('.greeting');
assert.ok(element, 'greeting element exists');
assert.equal(element.textContent.trim(), 'Hello, World!', 'shows greeting');
assert.ok(element.classList.contains('greeting'), 'has greeting class');
});
});
```
**Correct (expressive qunit-dom):**
```javascript
// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
module('Integration | Component | greeting', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><Greeting @name="World" /></template>);
assert.dom('.greeting').exists('greeting element exists');
assert.dom('.greeting').hasText('Hello, World!', 'shows greeting');
});
});
```
### Common Assertions
**Existence and Visibility:**
```javascript
test('element visibility', async function (assert) {
await render(
<template>
<MyComponent />
</template>,
);
// Element exists in DOM
assert.dom('[data-test-output]').exists();
// Element doesn't exist
assert.dom('[data-test-deleted]').doesNotExist();
// Element is visible (not display: none or visibility: hidden)
assert.dom('[data-test-visible]').isVisible();
// Element is not visible
assert.dom('[data-test-hidden]').isNotVisible();
// Count elements
assert.dom('[data-test-item]').exists({ count: 3 });
});
```
**Text Content:**
```javascript
test('text assertions', async function (assert) {
await render(<template><Article @title="Hello World" /></template>);
// Exact text match
assert.dom('h1').hasText('Hello World');
// Contains text (partial match)
assert.dom('p').containsText('Hello');
// Any text exists
assert.dom('h1').hasAnyText();
// No text
assert.dom('.empty').hasNoText();
});
```
**Attributes:**
```javascript
test('attribute assertions', async function (assert) {
await render(<template><Button @disabled={{true}} /></template>);
// Has attribute (any value)
assert.dom('button').hasAttribute('disabled');
// Has specific attribute value
assert.dom('button').hasAttribute('type', 'submit');
// Attribute value matches regex
assert.dom('a').hasAttribute('href', /^https:\/\//);
// Doesn't have attribute
assert.dom('button').doesNotHaveAttribute('aria-hidden');
// Has ARIA attributes
assert.dom('[role="button"]').hasAttribute('aria-label', 'Close dialog');
});
```
**Classes:**
```javascript
test('class assertions', async function (assert) {
await render(<template><Card @status="active" /></template>);
// Has single class
assert.dom('.card').hasClass('active');
// Doesn't have class
assert.dom('.card').doesNotHaveClass('disabled');
// Has no classes at all
assert.dom('.plain').hasNoClass();
});
```
**Form Elements:**
```javascript
test('form assertions', async function (assert) {
await render(
<template>
<form>
<input type="text" value="hello" />
<input type="checkbox" checked />
<input type="radio" disabled />
<select>
<option selected>Option 1</option>
</select>
</form>
</template>,
);
// Input value
assert.dom('input[type="text"]').hasValue('hello');
// Checkbox/radio state
assert.dom('input[type="checkbox"]').isChecked();
assert.dom('input[type="checkbox"]').isNotChecked();
// Disabled state
assert.dom('input[type="radio"]').isDisabled();
assert.dom('input[type="text"]').isNotDisabled();
// Required state
assert.dom('input').isRequired();
assert.dom('input').isNotRequired();
// Focus state
assert.dom('input').isFocused();
assert.dom('input').isNotFocused();
});
```
### Chaining Assertions
You can chain multiple assertions on the same element:
```javascript
test('chained assertions', async function (assert) {
await render(<template><Button @variant="primary" @disabled={{false}} /></template>);
assert.dom('button')
.exists()
.hasClass('btn-primary')
.hasAttribute('type', 'button')
.isNotDisabled()
.hasText('Submit')
.isVisible();
});
```
### Custom Error Messages
Add custom messages to make failures clearer:
```javascript
test('custom messages', async function (assert) {
await render(<template><UserProfile @user={{this.user}} /></template>);
assert.dom('[data-test-username]')
.hasText(this.user.name, 'username is displayed correctly');
assert.dom('[data-test-avatar]')
.exists('user avatar should be visible');
});
```
### Testing Counts
```javascript
test('list items', async function (assert) {
await render(<template>
<TodoList @todos={{this.todos}} />
</template>);
// Exact count
assert.dom('[data-test-todo]').exists({ count: 5 });
// At least one
assert.dom('[data-test-todo]').exists({ count: 1 });
// None
assert.dom('[data-test-todo]').doesNotExist();
});
```
### Accessibility Testing
Use qunit-dom for basic accessibility checks:
```javascript
test('accessibility', async function (assert) {
await render(<template><Modal @onClose={{this.close}} /></template>);
// ARIA roles
assert.dom('[role="dialog"]').exists();
assert.dom('[role="dialog"]').hasAttribute('aria-modal', 'true');
// Labels
assert.dom('[aria-label="Close modal"]').exists();
// Focus management
assert.dom('[data-test-close-button]').isFocused();
// Required fields
assert.dom('input[name="email"]').hasAttribute('aria-required', 'true');
});
```
### Best Practices
1. **Use data-test attributes** for test selectors instead of classes:
```javascript
// Good
assert.dom('[data-test-submit-button]').exists();
// Avoid - classes can change
assert.dom('.btn.btn-primary').exists();
```
2. **Make assertions specific**:
```javascript
// Better - exact match
assert.dom('h1').hasText('Welcome');
// Less specific - could miss issues
assert.dom('h1').containsText('Welc');
```
3. **Use meaningful custom messages**:
```javascript
assert.dom('[data-test-error]').hasText('Invalid email', 'shows correct validation error');
```
4. **Combine with @ember/test-helpers**:
```javascript
import { click, fillIn } from '@ember/test-helpers';
await fillIn('[data-test-email]', 'user@example.com');
await click('[data-test-submit]');
assert.dom('[data-test-success]').exists();
```
5. **Test user-visible behavior**, not implementation:
```javascript
// Good - tests what user sees
assert.dom('[data-test-greeting]').hasText('Hello, Alice');
// Avoid - tests implementation details
assert.ok(this.component.internalState === 'ready');
```
qunit-dom makes your tests more maintainable and easier to understand. It comes pre-installed with `ember-qunit`, so you can start using it immediately.
**References:**
- [qunit-dom Documentation](https://github.com/mainmatter/qunit-dom)
- [qunit-dom API](https://github.com/mainmatter/qunit-dom/blob/master/API.md)
- [Ember Testing Guide](https://guides.emberjs.com/release/testing/)

View File

@@ -0,0 +1,257 @@
---
title: Use Appropriate Render Patterns in Tests
impact: MEDIUM
impactDescription: Simpler test code and better readability
tags: testing, render, component-testing, test-helpers
---
## Use Appropriate Render Patterns in Tests
Choose the right rendering pattern based on whether your component needs arguments, blocks, or attributes in the test.
**Incorrect (using template tag unnecessarily):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
test('it renders', async function (assert) {
// ❌ Unnecessary template wrapper for component with no args
await render(
<template>
<LoadingSpinner />
</template>,
);
assert.dom('[data-test-spinner]').exists();
});
```
**Correct (direct component render when no args needed):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
test('it renders', async function (assert) {
// ✅ Simple: pass component directly when no args needed
await render(LoadingSpinner);
assert.dom('[data-test-spinner]').exists();
});
```
**Pattern 1: Direct component render (no args/blocks/attributes):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
module('Integration | Component | loading-spinner', function (hooks) {
setupRenderingTest(hooks);
test('it renders without arguments', async function (assert) {
// ✅ Simple: pass component directly when no args needed
await render(LoadingSpinner);
assert.dom('[data-test-spinner]').exists();
assert.dom('[data-test-spinner]').hasClass('loading');
});
});
```
**Pattern 2: Template tag render (with args/blocks/attributes):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders with arguments', async function (assert) {
const user = { name: 'John Doe', email: 'john@example.com' };
// ✅ Use template tag when passing arguments
await render(<template><UserCard @user={{user}} /></template>);
assert.dom('[data-test-user-name]').hasText('John Doe');
});
test('it renders with block content', async function (assert) {
// ✅ Use template tag when providing blocks
await render(
<template>
<UserCard>
<:header>Custom Header</:header>
<:body>Custom Content</:body>
</UserCard>
</template>,
);
assert.dom('[data-test-header]').hasText('Custom Header');
assert.dom('[data-test-body]').hasText('Custom Content');
});
test('it renders with HTML attributes', async function (assert) {
// ✅ Use template tag when passing HTML attributes
await render(<template><UserCard class="featured" data-test-featured /></template>);
assert.dom('[data-test-featured]').exists();
assert.dom('[data-test-featured]').hasClass('featured');
});
});
```
**Complete example showing both patterns:**
```glimmer-js
// tests/integration/components/button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import Button from 'my-app/components/button';
module('Integration | Component | button', function (hooks) {
setupRenderingTest(hooks);
test('it renders default button', async function (assert) {
// ✅ No args needed - use direct render
await render(Button);
assert.dom('button').exists();
assert.dom('button').hasText('Click me');
});
test('it renders with custom text', async function (assert) {
// ✅ Needs block content - use template tag
await render(
<template>
<Button>Submit Form</Button>
</template>,
);
assert.dom('button').hasText('Submit Form');
});
test('it handles click action', async function (assert) {
assert.expect(1);
const handleClick = () => {
assert.ok(true, 'Click handler called');
};
// ✅ Needs argument - use template tag
await render(
<template>
<Button @onClick={{handleClick}}>Click me</Button>
</template>,
);
await click('button');
});
test('it applies variant styling', async function (assert) {
// ✅ Needs argument - use template tag
await render(
<template>
<Button @variant="primary">Primary Button</Button>
</template>,
);
assert.dom('button').hasClass('btn-primary');
});
});
```
**Testing template-only components:**
```glimmer-js
// tests/integration/components/icon-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import Icon from 'my-app/components/icon';
module('Integration | Component | icon', function (hooks) {
setupRenderingTest(hooks);
test('it renders default icon', async function (assert) {
// ✅ Template-only component with no args - use direct render
await render(Icon);
assert.dom('[data-test-icon]').exists();
});
test('it renders specific icon', async function (assert) {
// ✅ Needs @name argument - use template tag
await render(<template><Icon @name="check" @size="large" /></template>);
assert.dom('[data-test-icon]').hasAttribute('data-icon', 'check');
assert.dom('[data-test-icon]').hasClass('icon-large');
});
});
```
**Decision guide:**
| Scenario | Pattern | Example |
| ----------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| No arguments, blocks, or attributes | `render(Component)` | `render(LoadingSpinner)` |
| Component needs arguments | `render(<template>...</template>)` | `render(<template><Card @title="Hello" /></template>)` |
| Component receives block content | `render(<template>...</template>)` | `render(<template><Card>Content</Card></template>)` |
| Component needs HTML attributes | `render(<template>...</template>)` | `render(<template><Card class="featured" /></template>)` |
| Multiple test context properties | `render(<template>...</template>)` | `render(<template><Card @data={{this.data}} /></template>)` |
**Why this matters:**
- **Simplicity**: Direct render reduces boilerplate for simple cases
- **Clarity**: Template syntax makes data flow explicit when needed
- **Consistency**: Clear pattern helps teams write maintainable tests
- **Type Safety**: Both patterns work with TypeScript for component types
**Common patterns:**
```glimmer-js
// ✅ Simple component, no setup needed
await render(LoadingSpinner);
await render(Divider);
await render(Logo);
// ✅ Component with arguments from test context
await render(
<template><UserList @users={{this.users}} @onSelect={{this.handleSelect}} /></template>,
);
// ✅ Component with named blocks
await render(
<template>
<Modal>
<:header>Title</:header>
<:body>Content</:body>
<:footer><button>Close</button></:footer>
</Modal>
</template>,
);
// ✅ Component with splattributes
await render(
<template>
<Card class="highlighted" data-test-card role="article">
Card content
</Card>
</template>,
);
```
Using the appropriate render pattern keeps tests clean and expressive.
Reference: [Ember Testing Guide](https://guides.emberjs.com/release/testing/)

View File

@@ -0,0 +1,309 @@
---
title: Use Test Waiters for Async Operations
impact: HIGH
impactDescription: Reliable tests that don't depend on implementation details
tags: testing, async, test-waiters, waitFor, settled
---
## Use Test Waiters for Async Operations
Instrument async code with test waiters instead of using `waitFor()` or `waitUntil()` in tests. Test waiters abstract async implementation details so tests focus on user behavior rather than timing.
**Why Test Waiters Matter:**
Test waiters allow `settled()` and other test helpers to automatically wait for your async operations. This means:
- Tests don't need to know about implementation details (timeouts, polling intervals, etc.)
- Tests are written from a user's perspective ("click button, see result")
- Code refactoring doesn't break tests
- Tests are more reliable and less flaky
**Incorrect (testing implementation details):**
```glimmer-js
// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, waitFor } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';
module('Integration | Component | data-loader', function (hooks) {
setupRenderingTest(hooks);
test('it loads data', async function (assert) {
await render(<template><DataLoader /></template>);
await click('[data-test-load-button]');
// BAD: Test knows about implementation details
// If the component changes from polling every 100ms to 200ms, test breaks
await waitFor('[data-test-data]', { timeout: 5000 });
assert.dom('[data-test-data]').hasText('Loaded data');
});
});
```
**Correct (using test waiters):**
```glimmer-js
// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('data-loader');
export class DataLoader extends Component {
@tracked data = null;
@tracked isLoading = false;
loadData = async () => {
// Register the async operation with test waiter
const token = waiter.beginAsync();
try {
this.isLoading = true;
// Simulate async data loading
const response = await fetch('/api/data');
this.data = await response.json();
} finally {
this.isLoading = false;
// Always end the async operation, even on error
waiter.endAsync(token);
}
};
<template>
<div>
<button {{on "click" this.loadData}} data-test-load-button>
Load Data
</button>
{{#if this.isLoading}}
<div data-test-loading>Loading...</div>
{{/if}}
{{#if this.data}}
<div data-test-data>{{this.data}}</div>
{{/if}}
</div>
</template>
}
```
```glimmer-js
// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, settled } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';
module('Integration | Component | data-loader', function (hooks) {
setupRenderingTest(hooks);
test('it loads data', async function (assert) {
await render(<template><DataLoader /></template>);
await click('[data-test-load-button]');
// GOOD: settled() automatically waits for test waiters
// No knowledge of timing needed - tests from user's perspective
await settled();
assert.dom('[data-test-data]').hasText('Loaded data');
});
});
```
**Test waiter with cleanup:**
```glimmer-js
// app/components/polling-widget.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('polling-widget');
export class PollingWidget extends Component {
@tracked status = 'idle';
intervalId = null;
token = null;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, () => {
this.stopPolling();
});
}
startPolling = () => {
// Register async operation
this.token = waiter.beginAsync();
this.intervalId = setInterval(() => {
this.checkStatus();
}, 1000);
};
stopPolling = () => {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
// End async operation on cleanup
if (this.token) {
waiter.endAsync(this.token);
this.token = null;
}
};
checkStatus = async () => {
const response = await fetch('/api/status');
this.status = await response.text();
if (this.status === 'complete') {
this.stopPolling();
}
};
<template>
<div>
<button {{on "click" this.startPolling}} data-test-start>
Start Polling
</button>
<div data-test-status>{{this.status}}</div>
</div>
</template>
}
```
**Test waiter with Services:**
```glimmer-js
// app/services/data-sync.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('data-sync-service');
export class DataSyncService extends Service {
@tracked isSyncing = false;
async sync() {
const token = waiter.beginAsync();
try {
this.isSyncing = true;
const response = await fetch('/api/sync', { method: 'POST' });
const result = await response.json();
return result;
} finally {
this.isSyncing = false;
waiter.endAsync(token);
}
}
}
```
```glimmer-js
// tests/unit/services/data-sync-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { settled } from '@ember/test-helpers';
module('Unit | Service | data-sync', function (hooks) {
setupTest(hooks);
test('it syncs data', async function (assert) {
const service = this.owner.lookup('service:data-sync');
// Start async operation
const syncPromise = service.sync();
// No need for manual waiting - settled() handles it
await settled();
const result = await syncPromise;
assert.ok(result, 'Sync completed successfully');
});
});
```
**Multiple concurrent operations:**
```glimmer-js
// app/components/parallel-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('parallel-loader');
export class ParallelLoader extends Component {
@tracked results = [];
loadAll = async () => {
const urls = ['/api/data1', '/api/data2', '/api/data3'];
// Each request gets its own token
const requests = urls.map(async (url) => {
const token = waiter.beginAsync();
try {
const response = await fetch(url);
return await response.json();
} finally {
waiter.endAsync(token);
}
});
this.results = await Promise.all(requests);
};
<template>
<button {{on "click" this.loadAll}} data-test-load-all>
Load All
</button>
{{#each this.results as |result|}}
<div data-test-result>{{result}}</div>
{{/each}}
</template>
}
```
**Benefits:**
1. **User-focused tests**: Tests describe user actions, not implementation
2. **Resilient to refactoring**: Change timing/polling without breaking tests
3. **No arbitrary timeouts**: Tests complete as soon as operations finish
4. **Automatic waiting**: `settled()`, `click()`, etc. wait for all registered operations
5. **Better debugging**: Test waiters show pending operations when tests hang
**When to use test waiters:**
- Network requests (fetch, XHR)
- Timers and intervals (setTimeout, setInterval)
- Animations and transitions
- Polling operations
- Any async operation that affects rendered output
**When NOT needed:**
- ember-concurrency already registers test waiters automatically
- Promises that complete before render (data preparation in constructors)
- Operations that don't affect the DOM or component state
**Key principle:** If your code does something async that users care about, register it with a test waiter. Tests should never use `waitFor()` or `waitUntil()` - those are code smells indicating missing test waiters.
Reference: [@ember/test-waiters](https://github.com/emberjs/ember-test-waiters)

View File

@@ -0,0 +1,210 @@
---
title: VSCode Extensions and MCP Configuration for Ember Projects
impact: HIGH
impactDescription: Improves editor consistency and AI-assisted debugging setup
tags: tooling, vscode, mcp, glint, developer-experience
---
## VSCode Extensions and MCP Configuration for Ember Projects
Set up recommended VSCode extensions and Model Context Protocol (MCP) servers for optimal Ember development experience.
**Incorrect (no extension recommendations):**
```json
{
"recommendations": []
}
```
**Correct (recommended extensions for Ember):**
```json
{
"recommendations": [
"emberjs.vscode-ember",
"vunguyentuan.vscode-glint",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
```
## Recommended VSCode Extensions
Create a `.vscode/extensions.json` file in your project root to recommend extensions to all team members:
```json
{
"recommendations": [
"emberjs.vscode-ember",
"vunguyentuan.vscode-glint",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
```
### Core Ember Extensions
**ember-extension-pack** (or individual extensions):
- `emberjs.vscode-ember` - Ember.js language support
- Syntax highlighting for `.hbs`, `.gjs`, `.gts` files
- IntelliSense for Ember-specific patterns
- Code snippets for common Ember patterns
**Glint 2 Extension** (for TypeScript projects):
- `vunguyentuan.vscode-glint` - Type checking for Glimmer templates
- Real-time type errors in `.gts`/`.gjs` files
- Template-aware autocomplete
- Hover information for template helpers and components
Install instructions:
```bash
# Via command palette
# Press Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux)
# Type: "Extensions: Install Extensions"
# Search for "Ember" or "Glint"
```
## MCP (Model Context Protocol) Server Configuration
Configure MCP servers in `.vscode/settings.json` to integrate AI coding assistants with Ember-specific context:
```json
{
"github.copilot.enable": {
"*": true,
"yaml": false,
"plaintext": false,
"markdown": false
},
"mcp.servers": {
"ember-mcp": {
"command": "npx",
"args": ["@ember/mcp-server"],
"description": "Ember.js MCP Server - Provides Ember-specific context"
},
"chrome-devtools": {
"command": "npx",
"args": ["@modelcontextprotocol/server-chrome-devtools"],
"description": "Chrome DevTools MCP Server - Browser debugging integration"
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp-server"],
"description": "Playwright MCP Server - Browser automation and testing"
}
}
}
```
### MCP Server Benefits
**Ember MCP Server** (`@ember/mcp-server`):
- Ember API documentation lookup
- Component and helper discovery
- Addon documentation integration
- Routing and data layer context
**Chrome DevTools MCP** (`@modelcontextprotocol/server-chrome-devtools`):
- Live browser inspection
- Console debugging assistance
- Network request analysis
- Performance profiling integration
**Playwright MCP** (optional, `@playwright/mcp-server`):
- Test generation assistance
- Browser automation context
- E2E testing patterns
- Debugging test failures
## Complete VSCode Settings Example
```json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[glimmer-js]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[glimmer-ts]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.associations": {
"*.gjs": "glimmer-js",
"*.gts": "glimmer-ts"
},
"glint.enabled": true,
"glint.configPath": "./tsconfig.json",
"github.copilot.enable": {
"*": true
},
"mcp.servers": {
"ember-mcp": {
"command": "npx",
"args": ["@ember/mcp-server"],
"description": "Ember.js MCP Server"
},
"chrome-devtools": {
"command": "npx",
"args": ["@modelcontextprotocol/server-chrome-devtools"],
"description": "Chrome DevTools MCP Server"
}
}
}
```
## TypeScript Configuration (when using Glint)
Ensure your `tsconfig.json` has Glint configuration:
```json
{
"compilerOptions": {
// ... standard TS options
},
"glint": {
"environment": ["ember-loose", "ember-template-imports"]
}
}
```
## Installation Steps
1. **Install extensions** (prompted automatically when opening project with `.vscode/extensions.json`)
2. **Install Glint** (if using TypeScript):
```bash
npm install --save-dev @glint/core @glint/environment-ember-loose @glint/environment-ember-template-imports
```
3. **Configure MCP servers** in `.vscode/settings.json`
4. **Reload VSCode** to activate all extensions and MCP integrations
## Benefits
- **Consistent team setup**: All developers get same extensions
- **Type safety**: Glint provides template type checking
- **AI assistance**: MCP servers give AI tools Ember-specific context
- **Better DX**: Autocomplete, debugging, and testing integration
- **Reduced onboarding**: New team members get productive faster
## References
- [VSCode Ember Extension](https://marketplace.visualstudio.com/items?itemName=emberjs.vscode-ember)
- [Glint Documentation](https://typed-ember.gitbook.io/glint/)
- [MCP Protocol Specification](https://modelcontextprotocol.io/)
- [Ember Primitives VSCode Setup Example](https://github.com/universal-ember/ember-primitives/tree/main/.vscode)

View File

@@ -6,3 +6,7 @@
# Default NODE_ENV with vite build --mode=test is production
NODE_ENV=development
# OpenStreetMap OAuth
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org

View File

@@ -0,0 +1,14 @@
name-template: 'v$RESOLVED_VERSION'
tag-template: 'v$RESOLVED_VERSION'
version-resolver:
major:
labels:
- 'release/major'
minor:
labels:
- 'release/minor'
- 'feature'
patch:
labels:
- 'release/patch'
default: patch

View File

@@ -18,15 +18,15 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Lint
@@ -35,18 +35,16 @@ jobs:
test:
name: "Test"
runs-on: ubuntu-latest
container:
image: cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Tests

View File

@@ -0,0 +1,13 @@
name: Release Drafter
on:
pull_request:
types: [closed]
jobs:
release_drafter_job:
name: Update release notes draft
runs-on: ubuntu-latest
steps:
- name: Release Drafter
uses: https://github.com/raucao/gitea-release-drafter@dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -3,6 +3,7 @@
# compiled output
/dist/
/release/
# misc
/coverage/

View File

@@ -3,3 +3,4 @@
# compiled output
/dist/
/release/

View File

@@ -1,3 +1,7 @@
export default {
extends: ['stylelint-config-standard'],
rules: {
'no-descending-specificity': null,
'property-no-vendor-prefix': null,
},
};

View File

@@ -1,3 +1,6 @@
export default {
extends: 'recommended',
rules: {
'link-rel-noopener': 'off',
},
};

661
LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

View File

@@ -1,94 +1,76 @@
# Project Status: Marco
**Last Updated:** Wed Jan 21 2026
**Last Updated:** Wed Apr 1 2026
## Project Context
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris edition with GJS/GLIMMER), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**, using a custom module structure.
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**.
## What We Have Done
### 1. Map Integration
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
- Implemented a hybrid click handler:
- Detects clicks on visual vector tiles.
- Falls back to fetching authoritative data from an **Overpass API** service.
- Uses a **heuristic** (distance + type matching) to link visual clicks to API results (handling data desynchronization).
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic.
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
- **Smart Interaction:**
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
### 2. RemoteStorage Module (`@remotestorage/module-places`)
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
- **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
- **API:**
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
- configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
- **Optimization:** Supports efficient spatial querying via prefix loading.
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
### 3. App Infrastructure
- **Services:**
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
- `osm.js`: Fetches nearby POIs from Overpass API.
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
- `settings.js`: Persists user preferences (e.g., API provider).
- **UI Components:**
- `places-sidebar.gjs`: Displays a list of nearby POIs.
- `place-details.gjs`: Dedicated component for displaying rich place information.
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
- **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations.
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
- **CI/CD:** Gitea Actions for automated testing and release drafting.
### 4. Routing & Data Optimization
### 4. Routing & Architecture
- **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
- **Smart Navigation:**
- Direct hits redirect to details.
- Search results automatically resolve to existing **Bookmarks**.
- "Back" navigation returns to cached search results instantly.
### 5. Features
- **Search:** Typo-tolerant **Photon API** integration with location bias, debounce, query aborting, and loading indicators.
- **Category Search:** Quick search buttons/chips for POI categories, rendering distinct map markers with custom icons for results.
- **UI Enhancements:** Toast notifications for failed search requests
- **Creation & Editing:**
- "Crosshair" mode for precise location picking.
- Edit Title/Description for saved places.
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
- **Socials:** Place details now include Email, Facebook, and Instagram links.
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
## Current State
- **Repo:** The app runs via `pnpm start`.
- **Repo:** Runs via `pnpm start`.
- **Workflow:**
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:** Selected place is passed to the route (`transitionTo` with model), updating the URL to `/place/<id>` or `/place/osm:<type>:<id>` without re-fetching data.
4. Sidebar displays details via `<PlaceDetails>` component.
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
2. **Search:** Query via Photon -> List or Direct Result.
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
4. **Action:**
- **Save:** Persist to RemoteStorage.
- **Organize:** Add to "To Go" / "To Do" lists.
- **Edit:** Custom Title/Description.
5. **Sync:** Background check updates OSM data if changed.
## Files Currently in Focus
## Next Steps
- `app/components/place-details.gjs`: UI logic for place info.
- `app/routes/place.js`: Routing logic.
- `app/components/map.gjs`: Map rendering and interaction.
- `app/services/storage.js`: Data sync logic.
## Next Steps & Pending Tasks
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage and retry logic.
## Technical Constraints
- **Template Style:** Strict Mode GJS (`<template>`).
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
2. **Performance:** Monitor with large datasets.
3. **Refinement:** Polish list UI and interactions.

View File

@@ -1,7 +1,18 @@
# marco
<br>
This README outlines the details of collaborating on this Ember application.
A short introduction of this app could easily go here.
<div align="center">
<img src="public/icons/icon-rounded.svg" width="128" height="128" alt="Marco App Icon">
<h1>Marco</h1>
</div>
<br>
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
your privacy and choices. It allows users to connect their own remote storage
to sync place bookmarks across apps and devices.
This README outlines the details of collaborating on this Ember application, or
just building and deploying it for yourself.
## Prerequisites
@@ -42,14 +53,29 @@ Make use of the many generators for code, try `pnpm ember help generate` for mor
- `pnpm vite build --mode development` (development)
- `pnpm build` (production)
### App Icon
- `pnpm build:icons` generates PNGs of all sizes from `public/icons/icon.svg`
To run the script, you need `imagemagick` and `librsvg` installed:
- **Arch Linux:** `pacman -S imagemagick librsvg`
- **Ubuntu/Debian:** `apt install imagemagick librsvg2-bin`
### Deploying
Specify what it takes to deploy your app.
- `git push 5apps master` (needs collaborator permission on 5apps)
- Or deploy `release/` to any static file host (ideally routing all 404s to
`index.html` for launching with client-side routes to work)
## Further Reading / Useful Links
- [ember.js](https://emberjs.com/)
- [remoteStorage.js](https://remotestorage.io/rs.js/docs/)
- [@remotestorage/module-places](https://gitea.kosmos.org/raucao/remotestorage-module-places)
- [Vite](https://vite.dev)
- Development Browser Extensions
- [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
[1]: https://en.wikipedia.org/wiki/Marco_Polo

View File

@@ -0,0 +1,87 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component {
@service storage;
@service settings;
@tracked isUserMenuOpen = false;
@tracked searchQuery = '';
get hasQuery() {
return !!this.searchQuery;
}
@action
toggleUserMenu() {
this.isUserMenuOpen = !this.isUserMenuOpen;
}
@action
closeUserMenu() {
this.isUserMenuOpen = false;
}
@action
handleQueryChange(query) {
this.searchQuery = query;
}
@action
handleChipSelect(category) {
this.searchQuery = category.label;
// The existing logic in CategoryChips triggers the route transition.
// This update simply fills the search box.
}
<template>
<header class="app-header">
<div class="header-left">
<SearchBox
@query={{this.searchQuery}}
@onToggleMenu={{@onToggleMenu}}
@onQueryChange={{this.handleQueryChange}}
/>
</div>
{{#if this.settings.showQuickSearchButtons}}
<div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} />
</div>
{{/if}}
<div class="header-right">
<div class="user-menu-container">
<button
class="user-btn btn-press"
type="button"
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}
>
<div class="user-avatar-placeholder">
<Icon @name="user" @size={{20}} @color="white" />
</div>
</button>
{{#if this.isUserMenuOpen}}
<UserMenu
@storage={{this.storage}}
@onClose={{this.closeUserMenu}}
/>
<div
class="menu-backdrop"
{{on "click" this.closeUserMenu}}
role="button"
></div>
{{/if}}
</div>
</div>
</header>
</template>
}

View File

@@ -0,0 +1,168 @@
import { on } from '@ember/modifier';
import Icon from '#components/icon';
<template>
{{! template-lint-disable no-nested-interactive }}
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>About</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<section class="about-section">
<p>
<strong>Marco</strong>
(as in
<a
href="https://en.wikipedia.org/wiki/Marco_Polo"
target="_blank"
rel="noopener"
>Marco Polo</a>) is an unhosted maps application that respects your
privacy and choices.
</p>
<p>
Connect your own
<a
href="https://remotestorage.io/"
target="_blank"
rel="noopener"
>remote storage</a>
to sync place bookmarks across apps and devices.
</p>
<details>
<summary>
<Icon @name="gift" @size={{20}} />
<span>Open Source</span>
</summary>
<div class="details-content">
<table>
<thead>
<tr>
<th>Source</th>
<th>License</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<a
href="https://gitea.kosmos.org/raucao/marco"
target="_blank"
rel="noopener"
>
Marco App
</a>
</td>
<td>
<a
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
target="_blank"
rel="noopener"
>
<abbr title="GNU Affero General Public License">AGPL</abbr>
</a>
</td>
</tr>
<tr>
<td>
<a
href="https://openstreetmap.org/copyright"
target="_blank"
rel="noopener"
>
Map Data
</a>
</td>
<td>
<a
href="https://opendatacommons.org/licenses/odbl/"
target="_blank"
rel="noopener"
>
<abbr
title="Open Data Commons Open Database License"
>ODbL</abbr>
</a>
</td>
</tr>
<tr>
<td>
<a
href="https://github.com/feathericons/feather"
target="_blank"
rel="noopener"
>
Feather Icons
</a>
</td>
<td>
<a
href="https://en.wikipedia.org/wiki/MIT_License"
target="_blank"
rel="noopener"
>
<abbr title="MIT License">MIT</abbr>
</a>
</td>
</tr>
<tr>
<td>
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
Pinhead Icons
</a>
</td>
<td>
<a
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
target="_blank"
rel="noopener"
>
Various
</a>
</td>
</tr>
</tbody>
</table>
</div>
</details>
<details>
<summary>
<Icon @name="heart" @size={{20}} @color="#e5533d" />
<span>Contribute</span>
</summary>
<div class="details-content">
<p>
<strong>Most impactful:</strong>
Add and improve data for points of interest in
<a
href="https://www.openstreetmap.org"
target="_blank"
rel="noopener"
>OpenStreetMap</a>.
</p>
<p>
<strong>Most appreciated:</strong>
Use this app as much as you can and
<a
href="https://community.remotestorage.io/t/marco-an-unhosted-maps-app/941"
target="_blank"
rel="noopener"
>submit feedback</a>
about your experience, problems, feature wishes, etc.
</p>
<p>
<strong>Most supportive:</strong>
Tell others about this app, on social media, in blog posts,
educational videos, etc.
</p>
</div>
</details>
</section>
</div>
</template>

View File

@@ -0,0 +1,36 @@
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { htmlSafe } from '@ember/template';
import Icon from '#components/icon';
import iconRounded from '../../icons/icon-rounded.svg?raw';
<template>
<div class="sidebar-header">
<h2>
<span class="app-logo-icon">
{{htmlSafe iconRounded}}
</span>
Marco
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<ul class="app-menu">
<li>
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
<Icon @name="settings" @size={{20}} />
<span>Settings</span>
</button>
</li>
<li>
<button type="button" {{on "click" (fn @onNavigate "about")}}>
<Icon @name="info" @size={{20}} />
<span>About</span>
</button>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,38 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper';
import eq from 'ember-truth-helpers/helpers/eq';
import AppMenuHome from './home';
import AppMenuSettings from './settings';
import AppMenuAbout from './about';
export default class AppMenu extends Component {
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
@action
setView(view) {
this.currentView = view;
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
{{else if (eq this.currentView "settings")}}
<AppMenuSettings
@onBack={{fn this.setView "menu"}}
@onClose={{@onClose}}
/>
{{else if (eq this.currentView "about")}}
<AppMenuAbout
@onBack={{fn this.setView "menu"}}
@onClose={{@onClose}}
/>
{{/if}}
</div>
</template>
}

View File

@@ -0,0 +1,129 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
export default class AppMenuSettings extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
@action
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
}
@action
updatePhotonApi(event) {
this.settings.updatePhotonApi(event.target.value);
}
<template>
<div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2>Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content">
<section class="settings-section">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
id="overpass-api"
class="form-control"
{{on "change" this.updateApi}}
>
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
<div class="form-group">
<label for="photon-api">Photon API Provider</label>
<select
id="photon-api"
class="form-control"
{{on "change" this.updatePhotonApi}}
>
{{#each this.settings.photonApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.photonApi) "selected"}}
>
{{api.name}}
</option>
{{/each}}
</select>
</div>
</section>
</div>
</template>
}

View File

@@ -0,0 +1,57 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { POI_CATEGORIES } from '../utils/poi-categories';
import { eq, and } from 'ember-truth-helpers';
export default class CategoryChipsComponent extends Component {
@service router;
@service mapUi;
get categories() {
return POI_CATEGORIES;
}
@action
searchCategory(category) {
// If passed an onSelect action, call it (e.g. to clear search box)
if (this.args.onSelect) {
this.args.onSelect(category);
}
let queryParams = { category: category.id, q: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
queryParams.lat = parseFloat(lat).toFixed(4);
queryParams.lon = parseFloat(lon).toFixed(4);
}
this.router.transitionTo('search', { queryParams });
}
<template>
<div class="category-chips-scroll">
<div class="category-chips-container">
{{#each this.categories as |category|}}
<button
type="button"
class="category-chip"
{{on "click" (fn this.searchCategory category)}}
aria-label={{category.label}}
disabled={{and
(eq this.mapUi.loadingState.type "category")
(eq this.mapUi.loadingState.value category.id)
}}
>
<Icon @name={{category.icon}} @size={{16}} />
<span>{{category.label}}</span>
</button>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -1,31 +1,10 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import globe from 'feather-icons/dist/icons/globe.svg?raw';
import home from 'feather-icons/dist/icons/home.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
const ICONS = {
clock,
globe,
home,
map,
mapPin,
navigation,
phone,
user,
settings
};
import { getIcon, isIconFilled } from '../utils/icons';
export default class IconComponent extends Component {
get svg() {
return ICONS[this.args.name];
return getIcon(this.args.name);
}
get size() {
@@ -33,20 +12,30 @@ export default class IconComponent extends Component {
}
get color() {
return this.args.color || '#888';
return this.args.color || '#898989';
}
get style() {
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
return htmlSafe(
`width:${this.size}px;height:${this.size}px;color:${this.color}`
);
}
get title() {
return this.args.title || '';
}
get isFilled() {
return this.args.filled || isIconFilled(this.args.name);
}
<template>
{{#if this.svg}}
<span class="icon" style={{this.style}} title={{this.title}}>
<span
class="icon {{if this.isFilled 'icon-filled'}}"
style={{this.style}}
title={{this.title}}
>
{{htmlSafe this.svg}}
</span>
{{/if}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +1,222 @@
import Component from '@glimmer/component';
import { fn } from '@ember/helper';
import { service } from '@ember/service';
import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize';
import { htmlSafe } from '@ember/template';
import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { mapToStorageSchema } from '../utils/place-mapping';
import { getSocialInfo } from '../utils/social-links';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false;
@tracked showLists = false;
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
get place() {
return this.args.place || {};
}
get saveablePlace() {
if (this.place.createdAt) {
return this.place;
}
return mapToStorageSchema(this.place);
}
get tags() {
return this.place.osmTags || {};
}
get name() {
return (
this.place.title ||
this.tags.name ||
this.tags['name:en'] ||
'Unnamed Place'
);
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
this.isEditing = true;
}
@action
cancelEditing() {
this.isEditing = false;
}
@action
toggleLists(event) {
// Prevent this click from propagating to the document listener
// which handles the "click outside" logic.
if (event) {
event.stopPropagation();
}
this.showLists = !this.showLists;
}
@action
closeLists() {
this.showLists = false;
}
@action
async saveChanges(changes) {
if (this.args.onSave) {
await this.args.onSave({
...this.place,
...changes,
});
}
this.isEditing = false;
}
get type() {
return (
this.tags.amenity ||
this.tags.shop ||
this.tags.tourism ||
this.tags.leisure ||
this.tags.historic ||
'Point of Interest'
);
return getPlaceType(this.tags);
}
get address() {
const t = this.tags;
const parts = [];
// Helper to get value from multiple keys
const get = (...keys) => {
for (const k of keys) {
if (t[k]) return t[k];
}
return null;
};
// Street + Number
if (t['addr:street']) {
let street = t['addr:street'];
if (t['addr:housenumber']) {
street += ` ${t['addr:housenumber']}`;
let street = get('addr:street', 'street');
const number = get('addr:housenumber', 'housenumber');
if (street) {
if (number) {
street = `${street} ${number}`;
}
parts.push(street);
}
// Postcode + City
if (t['addr:city']) {
let city = t['addr:city'];
if (t['addr:postcode']) {
city = `${t['addr:postcode']} ${city}`;
let city = get('addr:city', 'city');
const postcode = get('addr:postcode', 'postcode');
if (city) {
if (postcode) {
city = `${postcode} ${city}`;
}
parts.push(city);
}
// State + Country (if not already covered)
const state = get('addr:state', 'state');
const country = get('addr:country', 'country');
if (state && state !== city) parts.push(state);
if (country) parts.push(country);
if (parts.length === 0) return null;
return parts.join(', ');
}
formatMultiLine(val, type) {
if (!val) return null;
const parts = val
.split(';')
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return null;
if (type === 'phone') {
return htmlSafe(
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
);
}
if (type === 'email') {
return htmlSafe(
parts.map((p) => `<a href="mailto:${p}">${p}</a>`).join('<br>')
);
}
if (type === 'url') {
return htmlSafe(
parts
.map(
(url) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
url
)}</a>`
)
.join('<br>')
);
}
return htmlSafe(parts.join('<br>'));
}
get phone() {
return this.tags.phone || this.tags['contact:phone'];
const val = this.tags.phone || this.tags['contact:phone'];
return this.formatMultiLine(val, 'phone');
}
get email() {
const val = this.tags.email || this.tags['contact:email'];
return this.formatMultiLine(val, 'email');
}
get website() {
return this.place.url || this.tags.website || this.tags['contact:website'];
const val =
this.place.url || this.tags.website || this.tags['contact:website'];
return this.formatMultiLine(val, 'url');
}
getDomain(urlStr) {
try {
const url = new URL(urlStr);
return url.hostname;
} catch {
return urlStr;
}
}
get openingHours() {
return this.tags.opening_hours;
const val = this.tags.opening_hours;
return this.formatMultiLine(val);
}
get cuisine() {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
.split(';')
.map(c => capitalize.compute([c]))
.map(c => c.replace('_', ' '))
.map((c) => humanizeOsmTag(c))
.join(', ');
}
get facebook() {
return getSocialInfo(this.tags, 'facebook');
}
get instagram() {
return getSocialInfo(this.tags, 'instagram');
}
get wikipedia() {
return this.tags.wikipedia;
const val = this.tags.wikipedia;
if (!val) return null;
return val
.split(';')
.map((s) => s.trim())
.filter(Boolean)[0];
}
get geoLink() {
@@ -96,7 +231,7 @@ export default class PlaceDetails extends Component {
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return '';
return `${lat}, ${lon}`;
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
}
get osmUrl() {
@@ -106,65 +241,173 @@ export default class PlaceDetails extends Component {
return `https://www.openstreetmap.org/${type}/${id}`;
}
get gmapsUrl() {
const id = this.place.gmapsId || this.place.osmId;
if (!id) return null;
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
}
get showDescription() {
// If it's a Photon result, the description IS the address.
// Since we are showing the address in the meta section (bottom),
// we should hide the description to avoid duplication.
if (this.place.source === 'photon') return false;
// Otherwise (e.g. saved place with custom description), show it.
return !!this.place.description;
}
<template>
<div class="place-details">
{{#if this.isEditing}}
<PlaceEditForm
@place={{this.place}}
@onSave={{this.saveChanges}}
@onCancel={{this.cancelEditing}}
/>
{{else}}
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
{{#if this.showDescription}}
<p class="place-description">
{{this.place.description}}
</p>
{{/if}}
{{/if}}
<div class="actions">
<div class="save-button-wrapper">
<button
type="button"
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
{{on "click" (fn @onToggleSave this.place)}}
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
{{on "click" this.toggleLists}}
>
{{if this.place.createdAt "Saved ✓" "Save"}}
<Icon
@name="bookmark"
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
/>
{{if this.isSaved "Saved" "Save"}}
</button>
{{#if this.showLists}}
<PlaceListsManager
@place={{this.saveablePlace}}
@onClose={{this.closeLists}}
@isSaved={{this.isSaved}}
/>
{{/if}}
</div>
{{#if this.isSaved}}
<button
type="button"
class="btn btn-outline"
title="Edit"
disabled={{this.isEditing}}
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="var(--link-color)" />
Edit
</button>
{{/if}}
</div>
<div class="meta-info">
{{#if this.cuisine}}
<p>
<strong>Cuisine:</strong>
<p class="content-with-icon">
<Icon @name="fork-and-knife" @title="Cuisine" />
<span>
{{this.cuisine}}
</span>
</p>
{{/if}}
{{#if this.openingHours}}
<p class="content-with-icon">
<Icon @name="clock" @title="Opening hours" />
<span>{{this.openingHours}}</span>
<span>
{{this.openingHours}}
</span>
</p>
{{/if}}
{{#if this.phone}}
<p class="content-with-icon">
<Icon @name="phone" @title="Phone" />
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
<span>
{{this.phone}}
</span>
</p>
{{/if}}
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">Website</a></span>
<span>
{{this.website}}
</span>
</p>
{{/if}}
{{#if this.email}}
<p class="content-with-icon">
<Icon @name="mail" @title="Email" />
<span>
{{this.email}}
</span>
</p>
{{/if}}
{{#if this.facebook}}
<p class="content-with-icon">
<Icon @name="facebook" @title="Facebook" />
<span>
<a
href={{this.facebook.url}}
target="_blank"
rel="noopener noreferrer"
>
{{this.facebook.username}}
</a>
</span>
</p>
{{/if}}
{{#if this.instagram}}
<p class="content-with-icon">
<Icon @name="instagram" @title="Instagram" />
<span>
<a
href={{this.instagram.url}}
target="_blank"
rel="noopener noreferrer"
>
{{this.instagram.username}}
</a>
</span>
</p>
{{/if}}
{{#if this.wikipedia}}
<p>
<strong>Wikipedia:</strong>
<a href="https://wikipedia.org/wiki/{{this.wikipedia}}" target="_blank" rel="noopener noreferrer">Article</a>
<p class="content-with-icon">
<Icon @name="wikipedia" @title="Wikipedia" />
<span>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>
Wikipedia
</a>
</span>
</p>
{{/if}}
<hr class="meta-divider">
</div>
<div class="meta-info">
{{#if this.address}}
<p class="content-with-icon">
@@ -174,7 +417,7 @@ export default class PlaceDetails extends Component {
{{/if}}
<p class="content-with-icon">
<Icon @name="mapPin" @title="Geo link" />
<Icon @name="map-pin" @title="Geo link" />
<span>
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
{{this.visibleGeoLink}}
@@ -184,7 +427,7 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}}
<p class="content-with-icon">
<Icon @name="map" @title="OSM ID" />
<Icon @name="map" />
<span>
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
OpenStreetMap
@@ -192,6 +435,22 @@ export default class PlaceDetails extends Component {
</span>
</p>
{{/if}}
{{#if this.gmapsUrl}}
<p class="content-with-icon">
<Icon @name="map" />
<span>
<a
href={{this.gmapsUrl}}
target="_blank"
rel="noopener noreferrer"
>
Google Maps
</a>
</span>
</p>
{{/if}}
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceEditForm extends Component {
@tracked title = '';
@tracked description = '';
constructor() {
super(...arguments);
this.title = this.args.place?.title || '';
this.description = this.args.place?.description || '';
}
get shouldAutofocus() {
if (typeof window !== 'undefined') {
return window.innerWidth > 768;
}
return false;
}
@action
handleSubmit(event) {
event.preventDefault();
if (this.args.onSave) {
this.args.onSave({
title: this.title,
description: this.description,
});
}
}
@action
updateTitle(e) {
this.title = e.target.value;
}
@action
updateDescription(e) {
this.description = e.target.value;
}
<template>
<form class="edit-form" {{on "submit" this.handleSubmit}}>
<div class="form-group">
<label for="edit-title">Title</label>
{{! template-lint-disable no-autofocus-attribute }}
<input
id="edit-title"
type="text"
value={{this.title}}
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus={{this.shouldAutofocus}}
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.description}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
placeholder="Add some details..."
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button
type="button"
class="btn btn-outline"
{{on "click" @onCancel}}
>Cancel</button>
</div>
</form>
</template>
}

View File

@@ -0,0 +1,135 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { htmlSafe } from '@ember/template';
import onClickOutside from '../modifiers/on-click-outside';
export default class PlaceListsManager extends Component {
@service storage;
@service router;
@tracked _forceClear = false;
get isSaved() {
return this.args.isSaved;
}
get placeListIds() {
if (this._forceClear) return [];
return this.args.place._listIds || [];
}
styleFor(color) {
return htmlSafe(`background-color: ${color}`);
}
@action
isInList(list) {
if (!this.placeListIds) return false;
return this.placeListIds.includes(list.id);
}
@action
async toggleSaved() {
if (this.isSaved) {
const { osmId, osmType } = this.args.place;
await this.storage.removePlace(this.args.place);
// Clean up the local object reference immediately to prevent UI flicker
// or stale state if the transition is delayed/cancelled.
if (this.args.place) {
this.args.place.id = null;
this.args.place.createdAt = null;
this.args.place._listIds = [];
this._forceClear = true;
}
// Transition immediately to the canonical state
if (osmId && osmType) {
// Create a transient copy that looks like a fresh OSM result
const rawPlace = { ...this.args.place };
delete rawPlace.id;
delete rawPlace.createdAt;
delete rawPlace._listIds;
// Transition to the place route using the raw object
// This updates the URL to 'osm:...' and renders immediately
this.router.transitionTo('place', rawPlace);
} else {
// Custom place deleted -> go home
this.router.transitionTo('index');
}
if (this.args.onClose) this.args.onClose();
} else {
await this.storage.storePlace(this.args.place);
}
}
@action
async toggleList(list) {
const isMember = this.placeListIds.includes(list.id);
const shouldAdd = !isMember;
if (shouldAdd && !this.isSaved) {
// Auto-save if adding to list
await this.storage.storePlace(this.args.place);
}
try {
// Toggle membership
// We must pass the SAVED place (with ID) to the toggle function
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
// StorageService.storePlace returns the new object.
// But togglePlaceList handles saving internally if ID is missing.
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
} catch (e) {
console.error(e);
alert('Failed to update list: ' + e.message);
}
}
<template>
<div class="place-lists-manager" {{onClickOutside @onClose}}>
<div class="list-item master-toggle">
<label>
<input
type="checkbox"
checked={{this.isSaved}}
{{on "change" this.toggleSaved}}
/>
<span class="list-color"></span>
<span class="list-name">Saved places</span>
</label>
</div>
<div class="divider"></div>
<div class="lists-container">
{{#each this.storage.lists as |list|}}
<div class="list-item">
<label>
<input
type="checkbox"
checked={{this.isInList list}}
{{on "change" (fn this.toggleList list)}}
disabled={{unless this.isSaved true}}
/>
{{! template-lint-disable no-inline-styles }}
<span
class="list-color"
style={{this.styleFor list.color}}
></span>
<span class="list-name">{{list.title}}</span>
</label>
</div>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -4,10 +4,32 @@ import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import eq from 'ember-truth-helpers/helpers/eq';
import PlaceDetails from './place-details';
import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { getLocalizedName, getPlaceType } from '../utils/osm';
export default class PlacesSidebar extends Component {
@service storage;
@service router;
@service mapUi;
@action
createNewPlace() {
const qp = this.router.currentRoute.queryParams;
const lat = qp.lat;
const lon = qp.lon;
if (lat && lon) {
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
} else {
// Fallback (shouldn't happen in search context)
this.router.transitionTo('place.new', {
queryParams: { lat: 0, lon: 0 },
});
}
}
@action
selectPlace(place) {
@@ -22,32 +44,6 @@ export default class PlacesSidebar extends Component {
if (this.args.onSelect) {
this.args.onSelect(null);
}
// Fallback logic: if no list available, close sidebar
if (!this.args.places || this.args.places.length === 0) {
if (this.args.onClose) {
this.args.onClose();
}
}
}
get geoLink() {
if (!this.args.selectedPlace) return '#';
const p = this.args.selectedPlace;
// geo:lat,lon?q=lat,lon(Label)
const label = encodeURIComponent(
p.title ||
p.tags?.name ||
p.tags?.['name:en'] ||
'Location'
);
return `geo:${p.lat},${p.lon}?q=${p.lat},${p.lon}(${label})`;
}
get visibleGeoLink() {
if (!this.args.selectedPlace) return '';
const p = this.args.selectedPlace;
return `geo:${p.lat},${p.lon}`;
}
@action
@@ -55,37 +51,29 @@ export default class PlacesSidebar extends Component {
if (!place) return;
if (place.createdAt) {
// It's a saved bookmark -> Delete it
if (confirm(`Delete "${place.title}"?`)) {
// Direct delete without confirmation
try {
if (place.id && place.geohash) {
await this.storage.places.remove(place.id, place.geohash);
console.log('Place deleted:', place.title);
await this.storage.removePlace(place);
console.debug('Place deleted:', place.title);
// Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined
createdAt: undefined,
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
}
@@ -93,18 +81,14 @@ export default class PlacesSidebar extends Component {
if (this.args.onClose) {
this.args.onClose();
}
} else {
alert('Cannot delete: Missing ID or Geohash');
}
} catch (e) {
console.error('Failed to delete:', e);
alert('Failed to delete: ' + e.message);
}
}
} else {
// It's a fresh POI -> Save it
const placeData = {
title: place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
title: getLocalizedName(place.osmTags, 'Untitled Place'),
lat: place.lat,
lon: place.lon,
tags: [],
@@ -115,8 +99,8 @@ export default class PlacesSidebar extends Component {
};
try {
const savedPlace = await this.storage.places.store(placeData);
console.log('Place saved:', placeData.title);
const savedPlace = await this.storage.storePlace(placeData);
console.debug('Place saved:', placeData.title);
// Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) {
@@ -139,6 +123,32 @@ export default class PlacesSidebar extends Component {
}
}
@action
async updateBookmark(updatedPlace) {
try {
const savedPlace = await this.storage.updatePlace(updatedPlace);
console.debug('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Update local view
if (this.args.onUpdate) {
this.args.onUpdate(savedPlace);
}
} catch (e) {
console.error('Failed to update place:', e);
alert('Failed to update place: ' + e.message);
}
}
get isNearbySearch() {
const qp = this.router.currentRoute.queryParams;
return !qp.q && !qp.category && qp.lat && qp.lon;
}
<template>
<div class="sidebar">
<div class="sidebar-header">
@@ -147,16 +157,20 @@ export default class PlacesSidebar extends Component {
type="button"
class="back-btn"
{{on "click" this.clearSelection}}
></button>
<h2>Details</h2>
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
<h2>Nearby Places</h2>
{{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2>
{{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
{{/if}}
<button
type="button"
class="close-btn"
{{on "click" @onClose}}
>×</button>
{{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x"
@size={{20}}
@color="#333"
/></button>
</div>
<div class="sidebar-content">
@@ -164,6 +178,7 @@ export default class PlacesSidebar extends Component {
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}}
/>
{{else}}
{{#if @places}}
@@ -181,21 +196,40 @@ export default class PlacesSidebar extends Component {
place.osmTags.name:en
"Unnamed Place"
}}</div>
<div class="place-type">{{or
place.osmTags.amenity
place.osmTags.shop
place.osmTags.tourism
place.osmTags.leisure
place.osmTags.historic
}}</div>
<div class="place-type">
{{#if (eq place.source "osm")}}
{{humanizeOsmTag place.type}}
{{else if (eq place.source "photon")}}
{{place.description}}
{{else}}
{{#if place.osmTags}}
{{humanizeOsmTag (getPlaceType place.osmTags)}}
{{else if place.description}}
{{place.description}}
{{/if}}
{{/if}}
</div>
</button>
</li>
{{/each}}
</ul>
{{else}}
{{#if this.isNearbySearch}}
<p class="empty-state">No places found nearby.</p>
{{else}}
<p class="empty-state">No results found.</p>
{{/if}}
{{/if}}
<button
type="button"
class="btn btn-outline create-place"
{{on "click" this.createNewPlace}}
>
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
Create new place
</button>
{{/if}}
</div>
</div>
</template>

View File

@@ -0,0 +1,281 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { task, timeout } from 'ember-concurrency';
import Icon from '#components/icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { POI_CATEGORIES } from '../utils/poi-categories';
import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@service osm;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@tracked _internalQuery = '';
@tracked results = [];
@tracked isFocused = false;
@tracked isLoading = false;
get query() {
return this.args.query ?? this._internalQuery;
}
set query(value) {
this._internalQuery = value;
}
get showPopover() {
return this.isFocused && this.results.length > 0;
}
@action
handleInput(event) {
const value = event.target.value;
this.query = value;
if (this.args.onQueryChange) {
this.args.onQueryChange(value);
}
if (value.length < 2) {
this.results = [];
return;
}
this.searchTask.perform(value);
}
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
const query = typeof term === 'string' ? term : this.query;
if (query.length < 2) return;
this.isLoading = true;
try {
// Use map center if available for location bias
let lat, lon;
if (this.mapUi.currentCenter) {
({ lat, lon } = this.mapUi.currentCenter);
}
// Filter categories
const q = query.toLowerCase();
const categoryMatches = POI_CATEGORIES.filter((c) =>
c.label.toLowerCase().includes(q)
).map((c) => ({
source: 'category',
title: c.label,
id: c.id,
icon: 'search',
}));
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
} catch (e) {
console.error('Search failed', e);
this.results = [];
} finally {
this.isLoading = false;
}
});
@action
handleFocus() {
this.isFocused = true;
this.mapUi.setSearchBoxFocus(true);
if (this.query.length >= 2 && this.results.length === 0) {
this.searchTask.perform();
}
}
@action
handleBlur() {
// Delay hiding so clicks on results can register
setTimeout(() => {
this.isFocused = false;
this.mapUi.setSearchBoxFocus(false);
}, 300);
}
@action
handleSubmit(event) {
event.preventDefault();
if (!this.query) return;
let queryParams = { q: this.query, selected: null, category: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
queryParams.lat = parseFloat(lat).toFixed(4);
queryParams.lon = parseFloat(lon).toFixed(4);
}
this.router.transitionTo('search', { queryParams });
this.isFocused = false;
}
@action
selectResult(place) {
if (place.source === 'category') {
this.query = place.title;
if (this.args.onQueryChange) {
this.args.onQueryChange(place.title);
}
this.results = [];
let lat = null,
lon = null;
if (this.mapUi.currentCenter) {
({ lat, lon } = this.mapUi.currentCenter);
lat = lat?.toString();
lon = lon?.toString();
}
this.router.transitionTo('search', {
queryParams: {
q: place.title,
category: place.id,
selected: null,
lat: lat,
lon: lon,
},
});
return;
}
this.query = place.title;
if (this.args.onQueryChange) {
this.args.onQueryChange(place.title);
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
if (place.osmId) {
// Format: osm:node:123
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
const id = `osm:${place.osmType}:${place.osmId}`;
this.router.transitionTo('place', id);
} else {
// Just a location (e.g. from Photon without OSM ID, though unlikely for Photon)
// Or we can treat it as a search query
this.router.transitionTo('search', {
queryParams: {
q: place.title,
lat: place.lat,
lon: place.lon,
selected: null,
category: null,
},
});
}
}
@action
clear() {
this.searchTask.cancelAll();
this.mapUi.stopLoading();
this.osm.cancelAll();
this.photon.cancelAll();
this.query = '';
this.results = [];
if (this.args.onQueryChange) {
this.args.onQueryChange('');
}
this.router.transitionTo('index');
}
<template>
<div class="search-box">
<form class="search-form" {{on "submit" this.handleSubmit}}>
<button
type="button"
class="menu-btn-integrated"
aria-label="Menu"
{{on "click" @onToggleMenu}}
>
<Icon @name="menu" @size={{20}} @color="#5f6368" />
</button>
<input
type="search"
class="search-input"
placeholder="Search places..."
aria-label="Search places"
value={{this.query}}
{{on "input" this.handleInput}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
autocomplete="off"
/>
<button type="submit" class="search-submit-btn" aria-label="Search">
{{#if
(or
(eq this.mapUi.loadingState.type "text")
(eq this.mapUi.loadingState.type "category")
)
}}
<Icon @name="loading-ring" @size={{20}} />
{{else}}
<Icon @name="search" @size={{20}} @color="#5f6368" />
{{/if}}
</button>
{{#if this.query}}
<button
type="button"
class="search-clear-btn"
{{on "click" this.clear}}
aria-label="Clear"
>
<Icon @name="x" @size={{20}} @color="#5f6368" />
</button>
{{/if}}
</form>
{{#if this.showPopover}}
<div class="search-results-popover">
<ul class="search-results-list">
{{#each this.results as |result|}}
<li>
<button
type="button"
class="search-result-item"
{{on "click" (fn this.selectResult result)}}
>
<div class="result-icon">
<Icon
@name={{if result.icon result.icon "map-pin"}}
@size={{16}}
@color="#666"
/>
</div>
<div class="result-info">
<span class="result-title">{{result.title}}</span>
{{#if (eq result.source "osm")}}
<span class="result-desc">{{humanizeOsmTag
result.type
}}</span>
{{else}}
{{#if result.description}}
<span class="result-desc">{{result.description}}</span>
{{/if}}
{{/if}}
</div>
</button>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</template>
}

14
app/components/toast.gjs Normal file
View File

@@ -0,0 +1,14 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
export default class ToastComponent extends Component {
@service toast;
<template>
{{#if this.toast.isVisible}}
<div class="toast-notification">
{{this.toast.message}}
</div>
{{/if}}
</template>
}

View File

@@ -0,0 +1,108 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@action
connectRS() {
this.args.onClose();
this.args.storage.connect();
}
@action
disconnectRS() {
this.args.storage.disconnect();
}
@action
connectOsm() {
this.args.onClose();
this.osmAuth.login();
}
@action
disconnectOsm() {
this.osmAuth.logout();
}
<template>
<div class="user-menu-popover">
<ul class="account-list">
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="remotestorage" @size={{18}} />
<span>RemoteStorage</span>
</div>
{{#if @storage.connected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectRS}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectRS}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
{{#if @storage.connected}}
{{@storage.userAddress}}
{{else}}
Not connected
{{/if}}
</div>
</li>
<li class="account-item">
<div class="account-header">
<div class="account-info">
<Icon @name="map" @size={{18}} />
<span>OpenStreetMap</span>
</div>
{{#if this.osmAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectOsm}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectOsm}}
>Connect</button>
{{/if}}
</div>
<div class="account-status">
{{#if this.osmAuth.isConnected}}
{{this.osmAuth.userDisplayName}}
{{else}}
Not connected
{{/if}}
</div>
</li>
<li class="account-item disabled">
<div class="account-header">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
</div>
<div class="account-status">
Coming soon
</div>
</li>
</ul>
</div>
</template>
}

11
app/controllers/search.js Normal file
View File

@@ -0,0 +1,11 @@
import Controller from '@ember/controller';
export default class SearchController extends Controller {
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
lat = null;
lon = null;
q = null;
selected = null;
category = null;
}

View File

@@ -1,8 +0,0 @@
import { helper } from '@ember/component/helper';
export function capitalize([str]) {
if (typeof str !== 'string') return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export default helper(capitalize);

View File

@@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
import { humanizeOsmTag as format } from '../utils/format-text';
export default helper(function humanizeOsmTag([text]) {
return format(text);
});

1
app/icons/270-ring.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -0,0 +1,45 @@
<svg
width="1024"
height="1024"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Background -->
<rect
x="0"
y="0"
width="1024"
height="1024"
rx="220"
fill="#F6E9A6"
/>
<!-- Subtle map grid (kept well outside safe zone) -->
<g stroke="#E6D88A" stroke-width="10" opacity="0.6">
<line x1="256" y1="0" x2="256" y2="1024" />
<line x1="512" y1="0" x2="512" y2="1024" />
<line x1="768" y1="0" x2="768" y2="1024" />
<line x1="0" y1="256" x2="1024" y2="256" />
<line x1="0" y1="512" x2="1024" y2="512" />
<line x1="0" y1="768" x2="1024" y2="768" />
</g>
<!-- Location pin (exact app shape, larger, centered, safe-zone compliant) -->
<!-- Safe zone target: ~680px diameter -->
<g
transform="
translate(512 512)
scale(22)
translate(-12 -12)
"
fill="#ea4335"
stroke="#b31412"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" fill="#b31412" stroke="none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
app/icons/nostrich-2.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" class="icon-nostrich" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.7084 10.1607C18.1683 13.3466 14.8705 14.0207 12.9733 13.9618C12.8515 13.958 12.7366 14.0173 12.6647 14.1157C12.4684 14.384 12.1547 14.7309 11.9125 14.7309C11.6405 14.7309 11.3957 15.254 11.284 15.5795C11.2723 15.6137 11.3059 15.6452 11.3403 15.634C14.345 14.6584 15.5241 14.3238 16.032 14.4178C16.4421 14.4937 17.209 15.8665 17.5413 16.5434C16.7155 16.5909 16.4402 15.8507 16.2503 15.7178C16.0985 15.6116 16.0415 16.0974 16.032 16.3536C15.8517 16.2587 15.6239 16.1259 15.6049 15.7178C15.5859 15.3098 15.3771 15.4142 15.2157 15.4332C15.0544 15.4521 12.5769 16.2493 12.2067 16.3536C11.8366 16.458 11.4094 16.6004 11.0582 16.8471C10.4697 17.1318 10.09 16.9325 9.98561 16.4485C9.90208 16.0614 10.4444 14.8701 10.726 14.3229C10.3779 14.4526 9.65529 14.7158 9.54898 14.7309C9.44588 14.7457 8.13815 15.7552 7.43879 16.3038C7.398 16.3358 7.37174 16.3827 7.36236 16.4336C7.25047 17.0416 6.89335 17.2118 6.27423 17.5303C5.77602 17.7867 4.036 20.4606 3.14127 21.9041C3.0794 22.0039 2.9886 22.0806 2.8911 22.1461C2.32279 22.5276 1.74399 23.4985 1.50923 23.9737C1.17511 23.0095 1.61048 22.1802 1.86993 21.886C1.75602 21.7873 1.49341 21.8449 1.37634 21.886C1.69907 20.7757 2.82862 20.7757 2.79066 20.7757C2.99948 20.5954 5.44842 17.0938 5.50538 16.9325C5.56187 16.7725 5.46892 16.0242 6.69975 15.6139C6.7193 15.6073 6.73868 15.5984 6.75601 15.5873C7.71493 14.971 8.43427 13.9774 8.67571 13.5542C7.39547 13.4662 5.92943 12.7525 5.16289 12.294C4.99765 12.1952 4.8224 12.1092 4.63108 12.0875C3.58154 11.9687 2.53067 12.6401 2.10723 13.0228C1.93258 12.7799 2.12938 12.0739 2.24961 11.7513C1.82437 11.6905 1.19916 12.308 0.939711 12.6243C0.658747 12.184 0.904907 11.397 1.06311 11.0585C0.501179 11.0737 0.120232 11.3306 0 11.4571C0.465109 7.99343 4.02275 9.00076 4.06259 9.04675C3.87275 8.84937 3.88857 8.59126 3.92021 8.48688C6.0749 8.54381 7.08105 8.18321 7.71702 7.81313C12.7288 5.01374 14.8882 6.73133 15.6856 7.1631C16.4829 7.59487 17.9304 7.77042 18.9318 7.37187C20.1278 6.83097 19.9478 5.43673 19.7054 4.90461C19.4397 4.32101 17.9399 3.51438 17.4084 2.49428C16.8768 1.47418 17.34 0.233672 17.9558 0.0607684C18.5425 -0.103972 18.9615 0.0876835 19.2831 0.378128C19.4974 0.571763 20.0994 0.710259 20.3509 0.800409C20.6024 0.890558 21.0201 1.00918 20.9964 1.08035C20.9726 1.15152 20.5699 1.14202 20.5075 1.14202C20.3794 1.14202 20.2275 1.161 20.3794 1.23217C20.5575 1.30439 20.8263 1.40936 20.955 1.47846C20.9717 1.48744 20.9683 1.51084 20.95 1.51577C20.0765 1.75085 19.2966 1.26578 18.7183 1.82526C18.1298 2.39463 19.3827 2.83114 20.0282 3.51438C20.6736 4.19762 21.3381 5.01372 20.8065 6.87365C20.395 8.31355 18.6703 9.53781 17.7795 10.0167C17.7282 10.0442 17.7001 10.1031 17.7084 10.1607Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

6
app/icons/nostrich.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" class="icon-nostrich-head" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.03377 4.84648C2.38935 5.60878 1.88639 6.49681 1.5799 7.4713C3.32454 7.07836 5.64286 6.98406 6.95527 6.88189C7.36392 5.20013 8.52701 3.91915 10.476 4.0056C11.3169 4.04489 12.0556 4.58714 12.5664 5.42017C12.9436 5.01937 13.4466 4.75218 14.1146 4.65787C14.1617 4.65787 14.2639 4.65001 14.3425 4.65001C12.9593 3.14114 10.9868 2.18237 8.77849 2.18237C8.3777 2.18237 7.98476 2.22167 7.59183 2.28454C7.51324 2.28454 7.41108 2.30026 7.27748 2.33169C7.26962 2.33169 7.2539 2.33169 7.24604 2.33169C7.23818 2.33169 7.23032 2.33169 7.21461 2.33169C5.69001 2.70105 4.54264 2.40242 3.89037 1.51438C3.81964 1.42008 3.54458 1.00357 3.45814 0.272705C2.97876 0.767805 2.66441 1.58511 2.9316 2.45743C3.14379 3.149 3.54458 3.51836 3.97681 3.73054C3.31668 3.76984 2.76657 3.6441 2.21646 3.22759C1.89425 2.98396 1.68992 2.71677 1.352 2.01734C1.03765 2.51244 1.06909 3.06255 1.13195 3.34547C1.21054 3.72268 1.40701 4.14706 1.65849 4.39068C2.04357 4.76789 2.59368 4.85434 3.04162 4.84648H3.03377Z" fill="currentColor"/>
<path d="M10.4837 11.3458C11.4602 11.3458 12.2519 9.99116 12.2519 8.32016C12.2519 6.64917 11.4602 5.29456 10.4837 5.29456C9.50711 5.29456 8.71545 6.64917 8.71545 8.32016C8.71545 9.99116 9.50711 11.3458 10.4837 11.3458Z" fill="currentColor"/>
<path d="M14.3737 10.615C15.1376 10.615 15.7569 9.53831 15.7569 8.21019C15.7569 6.88207 15.1376 5.80542 14.3737 5.80542C13.6099 5.80542 12.9906 6.88207 12.9906 8.21019C12.9906 9.53831 13.6099 10.615 14.3737 10.615Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.52542 23.9833C7.53337 23.6314 7.66454 22.5232 8.7864 20.3047C9.2815 19.3381 10.4053 18.0021 11.2462 17.2791C11.6941 16.8862 12.1421 16.5561 12.5822 16.2496C12.8101 16.116 13.0222 15.9745 13.2266 15.8252C16.9076 13.5684 20.157 14.0396 22.8528 14.4306L22.9321 14.4421C22.9321 14.4421 23.5765 12.5246 20.9203 11.5344C19.4743 11 17.7689 10.5677 16.3465 10.2691C16.1422 10.6385 15.8828 10.9528 15.5763 11.1886C15.5721 11.1917 15.5678 11.195 15.5634 11.1983C15.3354 11.3696 14.795 11.7757 13.816 11.6601C13.313 11.5972 12.9279 11.3929 12.6215 11.0943C12.1028 11.9509 11.3562 12.5088 10.4917 12.5874C8.09483 12.7918 6.88458 10.7799 6.806 8.55591C5.00635 8.7288 2.55443 9.83688 1.24988 10.4813L1.25662 22.0396C2.92115 22.6846 5.41819 23.4807 7.52542 23.9833Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Creator: CorelDRAW X7 -->
<svg
xml:space="preserve"
width="24"
height="24"
version="1.1"
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
viewBox="0 0 249.99729 249.90068"
id="svg1"
sodipodi:docname="icon-square.svg"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="in"
inkscape:zoom="6.5838793"
inkscape:cx="2.1264059"
inkscape:cy="39.414453"
inkscape:window-width="2160"
inkscape:window-height="1281"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_x0020_1" />&#10; <defs
id="defs1"></defs>&#10; <g
id="Layer_x0020_1"
inkscape:label="Layer 1"
inkscape:groupmode="layer"
transform="translate(-66.822266,-0.16483529)">&#10; <metadata
id="CorelCorpID_0Corel-Layer" />&#10; <polygon
fill="currentColor"
points="228,181 370,100 511,181 652,263 370,425 87,263 87,263 0,213 0,213 0,311 0,378 0,427 0,476 86,525 185,582 370,689 554,582 653,525 653,590 653,592 370,754 0,542 0,640 185,747 370,853 554,747 739,640 739,525 739,476 739,427 739,378 653,427 370,589 86,427 86,361 185,418 370,524 554,418 653,361 739,311 739,213 554,107 370,0 185,107 58,180 144,230 "
id="polygon1"
transform="matrix(0.29308006,0,0,0.29308006,83.527829,-0.02838471)"
/>&#10; </g>&#10;</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

4
app/icons/wikipedia.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,21 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [callback]) => {
const handler = (event) => {
// Check if the click target is contained within the element
if (element && !element.contains(event.target)) {
callback(event);
}
};
// Delay attaching the listener to avoid catching the opening click
// (using a microtask or setTimeout 0)
const timer = setTimeout(() => {
document.addEventListener('click', handler);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handler);
};
});

Some files were not shown because too many files have changed in this diff Show More