diff --git a/.changeset/tough-seas-post.md b/.changeset/tough-seas-post.md new file mode 100644 index 0000000000..bbbf6c07a3 --- /dev/null +++ b/.changeset/tough-seas-post.md @@ -0,0 +1,5 @@ +--- +'@openproject/primer-view-components': minor +--- + +Add the FilterableTreeView component diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/aria-snapshot--after-interaction.yml index 9b39691213..57a8b347d0 100644 --- a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/aria-snapshot--after-interaction.yml +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/aria-snapshot--after-interaction.yml @@ -1,8 +1,8 @@ - button "Click me" - alertdialog "Delete dialog": - heading "Delete dialog" [level=1] - - 'button "Translation missing: en.button_close"' + - button "Close" - heading "Delete this item?" [level=2] - paragraph: Do you want to continue? - - 'button "Translation missing: en.button_cancel"' - - 'button "Translation missing: en.button_delete"' \ No newline at end of file + - button "Cancel" + - button "Delete" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark.png index c63d5915b8..244aaa4af9 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_colorblind.png index 99010f69be..a732e5e930 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_dimmed.png index febb853e33..a449bff3f5 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_dimmed.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_high_contrast.png index 30546be4e3..225f63a124 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light.png index 36a31323fd..cd74f7c096 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_colorblind.png index 2eb5e55619..16d1a098ad 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_high_contrast.png index b06c5aefcd..f4abf975b1 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/default/light_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/aria-snapshot--after-interaction.yml index ccacb0fead..44e48edb90 100644 --- a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/aria-snapshot--after-interaction.yml +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/aria-snapshot--after-interaction.yml @@ -1,10 +1,10 @@ - button "Click me" - alertdialog "Delete dialog": - heading "Delete dialog" [level=1] - - 'button "Translation missing: en.button_close"' + - button "Close" - heading "Permanently delete this item?" [level=2] - paragraph: This action is not reversible. Please proceed with caution. - checkbox "I understand that this deletion cannot be reversed": "1" - text: I understand that this deletion cannot be reversed - - 'button "Translation missing: en.button_cancel"' - - 'button "Translation missing: en.button_delete_permanently" [disabled]' \ No newline at end of file + - button "Cancel" + - button "Delete permanently" [disabled] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark.png index 90f1b9eb4a..265a28286c 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_colorblind.png index d4fd6b8e81..8d16981bc2 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_dimmed.png index b1eee96e61..ffe8455b99 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_dimmed.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_high_contrast.png index 9570987ad1..d390175965 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light.png index 70dd1b9e68..9cdcfaa28f 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_colorblind.png index 4bd0ad55e8..999c6eb661 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_high_contrast.png index 56d4ea1b22..50ee9c83a9 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/danger_dialog/with_confirmation_check_box/light_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/aria-snapshot--after-interaction.yml index fccda4d30f..c8092f7de3 100644 --- a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/aria-snapshot--after-interaction.yml +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/aria-snapshot--after-interaction.yml @@ -1,7 +1,7 @@ - button "Click me" - dialog "Success dialog": - heading "Success dialog" [level=1] - - 'button "Translation missing: en.button_close"' + - button "Close" - heading "Success" [level=2] - paragraph: Great! Everything worked well. - - 'button "Translation missing: en.button_close"' \ No newline at end of file + - button "Close" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark.png index a10643546e..586b9b3e8d 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_colorblind.png index 43b7ec7756..f5e80fe272 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_dimmed.png index a2811342e3..007c098405 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_dimmed.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_high_contrast.png index a53348dd57..eefb500037 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light.png index 21d8ff21a4..88262f6580 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_colorblind.png index e533238d9e..d5c7094c30 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_colorblind.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_high_contrast.png index cac62c617b..cf5657a2b6 100644 Binary files a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_high_contrast.png and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/feedback_dialog/default/light_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot--after-interaction.yml new file mode 100644 index 0000000000..91c93568ad --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot--after-interaction.yml @@ -0,0 +1,14 @@ +- list "Filter mode": + - listitem: + - button "Teacher" + - listitem: + - button "All" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot.yml new file mode 100644 index 0000000000..c40e22217f --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/aria-snapshot.yml @@ -0,0 +1,30 @@ +- list "Filter mode": + - listitem: + - button "Teacher" + - listitem: + - button "All" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Students" [expanded] [level=1] + - group: + - treeitem "Ravenclaw" [expanded] [level=2] + - group: + - treeitem "Luna Lovegood" [level=3] + - treeitem "Slytherin" [expanded] [level=2] + - group: + - treeitem "Draco Malfoy" [level=3] + - treeitem "Hufflepuff" [expanded] [level=2] + - group: + - treeitem "Susan Bones" [level=3] + - treeitem "Gryffindor" [expanded] [level=2] + - group: + - treeitem "Harry Potter" [level=3] + - treeitem "Ronald Weasley" [level=3] + - treeitem "Hermione Granger" [level=3] + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark.png new file mode 100644 index 0000000000..83d5e31735 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_colorblind.png new file mode 100644 index 0000000000..83d5e31735 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_dimmed.png new file mode 100644 index 0000000000..82db9642b1 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_high_contrast.png new file mode 100644 index 0000000000..1ce59bd002 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/default.png new file mode 100644 index 0000000000..90bacdf186 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/focused.png new file mode 100644 index 0000000000..70c8846da4 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light.png new file mode 100644 index 0000000000..176091694e Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_colorblind.png new file mode 100644 index 0000000000..176091694e Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_high_contrast.png new file mode 100644 index 0000000000..1c6c605564 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/custom_segmented_control/light_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot--after-interaction.yml new file mode 100644 index 0000000000..cf92391f5c --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot--after-interaction.yml @@ -0,0 +1,30 @@ +- list "Filter mode": + - listitem: + - button "All" + - listitem: + - button "Selected" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Students" [expanded] [level=1] + - group: + - treeitem "Ravenclaw" [expanded] [level=2] + - group: + - treeitem "Luna Lovegood" [level=3] + - treeitem "Slytherin" [expanded] [level=2] + - group: + - treeitem "Draco Malfoy" [level=3] + - treeitem "Hufflepuff" [expanded] [level=2] + - group: + - treeitem "Susan Bones" [level=3] + - treeitem "Gryffindor" [expanded] [level=2] + - group: + - treeitem "Harry Potter" [level=3] + - treeitem "Ronald Weasley" [level=3] + - treeitem "Hermione Granger" [level=3] + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot.yml new file mode 100644 index 0000000000..cf92391f5c --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/aria-snapshot.yml @@ -0,0 +1,30 @@ +- list "Filter mode": + - listitem: + - button "All" + - listitem: + - button "Selected" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Students" [expanded] [level=1] + - group: + - treeitem "Ravenclaw" [expanded] [level=2] + - group: + - treeitem "Luna Lovegood" [level=3] + - treeitem "Slytherin" [expanded] [level=2] + - group: + - treeitem "Draco Malfoy" [level=3] + - treeitem "Hufflepuff" [expanded] [level=2] + - group: + - treeitem "Susan Bones" [level=3] + - treeitem "Gryffindor" [expanded] [level=2] + - group: + - treeitem "Harry Potter" [level=3] + - treeitem "Ronald Weasley" [level=3] + - treeitem "Hermione Granger" [level=3] + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark.png new file mode 100644 index 0000000000..9bbf2fc847 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_colorblind.png new file mode 100644 index 0000000000..9bbf2fc847 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_dimmed.png new file mode 100644 index 0000000000..1482886022 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_high_contrast.png new file mode 100644 index 0000000000..ef3bb1afa6 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/default.png new file mode 100644 index 0000000000..cf5d9790b3 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/focused.png new file mode 100644 index 0000000000..4a8b432e7d Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light.png new file mode 100644 index 0000000000..aa0991e715 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_colorblind.png new file mode 100644 index 0000000000..aa0991e715 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_high_contrast.png new file mode 100644 index 0000000000..bf4b484f60 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/default/light_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot--after-interaction.yml new file mode 100644 index 0000000000..cf92391f5c --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot--after-interaction.yml @@ -0,0 +1,30 @@ +- list "Filter mode": + - listitem: + - button "All" + - listitem: + - button "Selected" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Students" [expanded] [level=1] + - group: + - treeitem "Ravenclaw" [expanded] [level=2] + - group: + - treeitem "Luna Lovegood" [level=3] + - treeitem "Slytherin" [expanded] [level=2] + - group: + - treeitem "Draco Malfoy" [level=3] + - treeitem "Hufflepuff" [expanded] [level=2] + - group: + - treeitem "Susan Bones" [level=3] + - treeitem "Gryffindor" [expanded] [level=2] + - group: + - treeitem "Harry Potter" [level=3] + - treeitem "Ronald Weasley" [level=3] + - treeitem "Hermione Granger" [level=3] + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot.yml new file mode 100644 index 0000000000..cf92391f5c --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/aria-snapshot.yml @@ -0,0 +1,30 @@ +- list "Filter mode": + - listitem: + - button "All" + - listitem: + - button "Selected" +- checkbox "Include sub-items": "1" +- text: Include sub-items Filter +- searchbox "Filter" +- button "Clear" +- tree: + - treeitem "Students" [expanded] [level=1] + - group: + - treeitem "Ravenclaw" [expanded] [level=2] + - group: + - treeitem "Luna Lovegood" [level=3] + - treeitem "Slytherin" [expanded] [level=2] + - group: + - treeitem "Draco Malfoy" [level=3] + - treeitem "Hufflepuff" [expanded] [level=2] + - group: + - treeitem "Susan Bones" [level=3] + - treeitem "Gryffindor" [expanded] [level=2] + - group: + - treeitem "Harry Potter" [level=3] + - treeitem "Ronald Weasley" [level=3] + - treeitem "Hermione Granger" [level=3] + - treeitem "Albus Dumbledore" [level=1] + - treeitem "Minerva McGonagall" [level=1] + - treeitem "Severus Snape" [level=1] + - treeitem "Rubeus Hagrid" [level=1] \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark.png new file mode 100644 index 0000000000..5093cc82dc Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_colorblind.png new file mode 100644 index 0000000000..5093cc82dc Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_dimmed.png new file mode 100644 index 0000000000..a9cc5269f5 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_dimmed.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_high_contrast.png new file mode 100644 index 0000000000..ef3bb1afa6 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/dark_high_contrast.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/default.png new file mode 100644 index 0000000000..cf5d9790b3 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/focused.png new file mode 100644 index 0000000000..4a8b432e7d Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light.png new file mode 100644 index 0000000000..aa0991e715 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_colorblind.png new file mode 100644 index 0000000000..aa0991e715 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_colorblind.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_high_contrast.png new file mode 100644 index 0000000000..bf4b484f60 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/open_project/filterable_tree_view/playground/light_high_contrast.png differ diff --git a/app/components/primer/alpha/segmented_control.ts b/app/components/primer/alpha/segmented_control.ts index 3883235f5f..5de4721746 100644 --- a/app/components/primer/alpha/segmented_control.ts +++ b/app/components/primer/alpha/segmented_control.ts @@ -1,7 +1,7 @@ import {controller, targets} from '@github/catalyst' @controller -class SegmentedControlElement extends HTMLElement { +export class SegmentedControlElement extends HTMLElement { @targets items: HTMLElement[] connectedCallback() { @@ -17,6 +17,21 @@ class SegmentedControlElement extends HTMLElement { button.closest('li.SegmentedControl-item')?.classList.add('SegmentedControl-item--selected') button.setAttribute('aria-current', 'true') + + this.dispatchEvent( + new CustomEvent('itemActivated', { + bubbles: true, + detail: { + item: button, + checked: false, + value: button.querySelector('.Button-label')?.textContent, + }, + }), + ) + } + + get current(): HTMLElement | null { + return this.querySelector('[aria-current=true]') } // Updates the button labels to have a data-content attribute with the text diff --git a/app/components/primer/alpha/stack.pcss b/app/components/primer/alpha/stack.pcss index ccf2f46cdc..b4926a865e 100644 --- a/app/components/primer/alpha/stack.pcss +++ b/app/components/primer/alpha/stack.pcss @@ -115,6 +115,11 @@ flex-wrap: nowrap; } + &[data-wrap='reverse'], + &[data-wrap-narrow='reverse'] { + flex-wrap: wrap-reverse; + } + /* @custom-media --veiwportRange-regular */ @media (min-width: 48rem) { &[data-padding-regular='none'] { @@ -203,6 +208,10 @@ &[data-wrap-regular='nowrap'] { flex-wrap: nowrap; } + + &[data-wrap-regular='reverse'] { + flex-wrap: wrap-reverse; + } } /* @custom-media --viewportRange-wide */ @@ -293,5 +302,9 @@ &[data-wrap-wide='nowrap'] { flex-wrap: nowrap; } + + &[data-wrap-wide='reverse'] { + flex-wrap: wrap-reverse; + } } } diff --git a/app/components/primer/alpha/stack.rb b/app/components/primer/alpha/stack.rb index bffbd8433d..0b46afc084 100644 --- a/app/components/primer/alpha/stack.rb +++ b/app/components/primer/alpha/stack.rb @@ -78,7 +78,8 @@ class WrapArg < Primer::ResponsiveArg OPTIONS = [ nil, DEFAULT, - :wrap + :wrap, + :reverse ].freeze def initialize(values) diff --git a/app/components/primer/open_project/filterable_tree_view.html.erb b/app/components/primer/open_project/filterable_tree_view.html.erb new file mode 100644 index 0000000000..07b122aaa4 --- /dev/null +++ b/app/components/primer/open_project/filterable_tree_view.html.erb @@ -0,0 +1,28 @@ +<%= render(Primer::BaseComponent.new(**@system_arguments)) do %> + <%= render(Primer::Alpha::Stack.new) do %> + <%= render(Primer::Alpha::Stack.new(wrap: :reverse, direction: :horizontal, align: :center)) do %> + <%= render(Primer::Alpha::Stack.new(wrap: :wrap, direction: :horizontal, align: :center)) do %> + <%= render(Primer::Alpha::StackItem.new) do %> + <%= render(@filter_mode_control) %> + <% end %> + <%= render(Primer::Alpha::StackItem.new) do %> + <%= render(@include_sub_items_check_box) do |input| %> + <% input.merge_input_arguments!(form: "") %> + <% end %> + <% end %> + <% end %> + <%= render(Primer::Alpha::StackItem.new(grow: true)) do %> + <%= render(@filter_input) do |input| %> + <%# exclude from form submissions %> + <% input.merge_input_arguments!(form: "") %> + <% end %> + <% end %> + <% end %> + <%= render(Primer::BaseComponent.new(tag: :div, hidden: true, data: { target: "filterable-tree-view.noResultsMessage" })) do %> + <%= render(Primer::OpenProject::TreeView.new) do |tree| %> + <% tree.with_leaf(**@no_results_node_arguments) %> + <% end %> + <% end %> + <%= render(@tree_view) %> + <% end %> +<% end %> diff --git a/app/components/primer/open_project/filterable_tree_view.rb b/app/components/primer/open_project/filterable_tree_view.rb new file mode 100644 index 0000000000..a94f915581 --- /dev/null +++ b/app/components/primer/open_project/filterable_tree_view.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + # A TreeView and associated filter controls for searching nested hierarchies. + # + # ## Filter controls + # + # `FilterableTreeView`s can be filtered using two controls, both present in the toolbar above the tree: + # + # 1. A free-form query string from a text input field. + # 2. A `SegmentedControl` with two options (by default): + # 1. The "Selected" option causes the component to only show checked nodes, provided they also satisfy the other + # filter criteria described here. + # 2. The "All" option causes the component to show all nodes, provided they also satisfy the other filter + # criteria described here. + # + # ## Custom filter modes + # + # In addition to the default filter modes of `'all'` and `'selected'` described above, `FilterableTreeView` supports + # adding custom filter modes. Adding a filter mode will cause its label to appear in the `SegmentedControl` in the + # toolbar, and will be passed as the third argument to the filter function (see below). + # + # Here's how to add a custom filter mode in addition to the default ones: + # + # ```erb + # <%= render(Primer::OpenProject::FilterableTreeView.new) do |tree_view| %> + # <%# remove this line to prevent adding the default modes %> + # <% tree_view.with_default_filter_modes %> + # <% tree_view.with_filter_mode(name: "Custom", system_arguments) + # <% end %> + # ``` + # + # ## Filter behavior + # + # By default, matching node text is identified by looking for an exact substring match, operating on a lowercased + # version of both the query string and the node text. For more information, and to provide a customized filter + # function, please see the section titled "Customizing the filter function" below. + # + # Nodes that match the filter appear as normal; nodes that do not match are presented as follows: + # + # 1. Leaf nodes are hidden. + # 2. Sub-tree nodes with no matching children are hidden. + # 3. Sub-tree nodes with at least one matching child are disabled but still visible. + # + # ## Checking behavior + # + # By default, checking a node in a `FilterableTreeView` checks only that node (i.e. no child nodes are checked). + # To aide in checking children in deeply nested or highly populated hierarchies, a third control exists in the + # toolbar: the "Include sub-items" check box. If this feature is turned on, checking sub-tree nodes causes all + # children, both leaf and sub-tree nodes, to also be checked recursively. Moreover, turning this feature on will + # cause the children of any previously checked nodes to be checked recursively. Unchecking a node while in + # "Include sub-items" mode will restore that sub-tree and all its children to their previously checked state, so as + # not to permanently override a user's selections. Unchecking the "Include sub-items" check box has a similar effect, + # i.e. restores all previous user selections under currently checked sub-trees. + # + # ## JavaScript API + # + # `FilterableTreeView` does not yet have an extensive JavaScript API, but this may change in the future as the + # component is further developed to fit additional use-cases. + # + # ### Customizing the filter function + # + # The filter function can be customized by setting the value of the `filterFn` property to a function with the + # following signature: + # + # ```typescript + # export type FilterFn = (node: HTMLElement, query: string, filterMode?: string) => Range[] | null + # ``` + # + # This function will be called once for each node in the tree every time filter controls change (i.e. when the + # filter mode or query string are altered). The function is called with the following arguments: + # + # |Argument |Description | + # |:-----------|:----------------------------------------------------------------| + # |`node` |The HTML node element, i.e. the element with `role=treeitem` set.| + # |`query` |The query string. | + # |`filterMode`|The filter mode, either `'all'` or `'selected'`. | + # + # The component expects the filter function to return specific values depending on the type of match: + # + # 1. No match - return `null` + # 2. Match but no highlights (eg. when the query string is empty) - return an empty array + # 3. Match with highlights - return a non-empty array of `Range` objects + # + # Example: + # + # ```javascript + # const filterableTreeView = document.querySelector('filterable-tree-view') + # filterableTreeView.filterFn = (node, query, filterMode) => { + # // custom filter implementation here + # } + # ``` + # + # You can read about `Range` objects here: https://developer.mozilla.org/en-US/docs/Web/API/Range. + # + # For a complete example demonstrating how to implement a working filter function complete with range highlighting, + # see the default filter function available in the `FilterableTreeViewElement` JavaScript class, which is part of + # the Primer source code. + # + # ### Events + # + # Currently `FilterableTreeView` does not emit any events aside from the events already emitted by the `TreeView` + # component. + class FilterableTreeView < Primer::Component + delegate :with_leaf, :with_sub_tree, to: :@tree_view + + DEFAULT_FILTER_INPUT_ARGUMENTS = { + name: :filter, + label: I18n.t(:button_filter), + type: :search, + leading_visual: { icon: :search }, + visually_hide_label: true, + show_clear_button: true, + } + + DEFAULT_FILTER_INPUT_ARGUMENTS.freeze + + DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS = { + aria: { + label: I18n.t("filterable_tree_view.filter_mode.label") + } + } + + DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS.freeze + + DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS = { + label: I18n.t("filterable_tree_view.include_sub_items"), + name: :include_sub_items + } + + DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS.freeze + + DEFAULT_FILTER_MODES = { + all: { + label: I18n.t("filterable_tree_view.filter_mode.all"), + selected: true, + }, + + selected: { + label: I18n.t("filterable_tree_view.filter_mode.selected"), + } + } + + DEFAULT_FILTER_MODES.freeze + + DEFAULT_NO_RESULTS_NODE_ARGUMENTS = { + label: I18n.t("filterable_tree_view.no_results_text") + } + + DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze + + # @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::OpenProject::TreeView) %> component. + # @param form_arguments [Hash] Form arguments that will be passed to the underlying <%= link_to_component(Primer::OpenProject::TreeView) %> component. These arguments allow the selections made within a `FilterableTreeView` to be submitted to the server as part of a Rails form. Pass the `builder:` and `name:` options to this hash. `builder:` should be an instance of `ActionView::Helpers::FormBuilder`, which are created by the standard Rails `#form_with` and `#form_for` helpers. The `name:` option is the desired name of the field that will be included in the params sent to the server on form submission. + # @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component. + # @param filter_mode_control_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::SegmentedControl) %> component. + # @param include_sub_items_check_box_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::CheckBox) %> component. + # @param no_results_node_arguments [Hash] Arguments that will be passed to a <%= link_to_component(Primer::OpenProject::TreeView::LeafNode) %> component that appears when no items match the filter criteria. + def initialize( + tree_view_arguments: {}, + form_arguments: {}, + filter_input_arguments: DEFAULT_FILTER_INPUT_ARGUMENTS.dup, + filter_mode_control_arguments: DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS.dup, + include_sub_items_check_box_arguments: DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS.dup, + no_results_node_arguments: DEFAULT_NO_RESULTS_NODE_ARGUMENTS.dup, + **system_arguments + ) + tree_view_arguments[:data] = merge_data( + tree_view_arguments, { + data: { target: "filterable-tree-view.treeViewList" } + } + ) + + @tree_view = Primer::OpenProject::TreeView.new( + form_arguments: form_arguments, + **tree_view_arguments + ) + + filter_input_arguments[:data] = merge_data( + filter_input_arguments, { + data: { target: "filterable-tree-view.filterInput" } + } + ) + + @filter_input = Primer::Alpha::TextField.new(**filter_input_arguments) + + filter_mode_control_arguments[:data] = merge_data( + filter_mode_control_arguments, { + data: { target: "filterable-tree-view.filterModeControlList" } + } + ) + + @filter_mode_control = Primer::Alpha::SegmentedControl.new(**filter_mode_control_arguments) + + include_sub_items_check_box_arguments[:data] = merge_data( + include_sub_items_check_box_arguments, { + data: { target: "filterable-tree-view.includeSubItemsCheckBox" } + } + ) + + @include_sub_items_check_box = Primer::Alpha::CheckBox.new(**include_sub_items_check_box_arguments) + + @system_arguments = deny_tag_argument(**system_arguments) + @system_arguments[:tag] = :"filterable-tree-view" + + @no_results_node_arguments = no_results_node_arguments + end + + def with_default_filter_modes + DEFAULT_FILTER_MODES.each do |name, system_arguments| + with_filter_mode(name: name, **system_arguments) + end + end + + def with_filter_mode(name:, **system_arguments) + system_arguments[:data] = merge_data( + system_arguments, { + data: { name: name } + } + ) + + @filter_mode_control.with_item(**system_arguments) + end + + def with_sub_tree(**system_arguments, &block) + @tree_view.with_sub_tree( + sub_tree_component_klass: SubTree, + **system_arguments, + select_variant: :multiple, + select_strategy: :self, + &block + ) + end + + def with_leaf(**system_arguments, &block) + @tree_view.with_leaf( + **system_arguments, + select_variant: :multiple, + &block + ) + end + + private + + def before_render + content + + if @filter_mode_control.items.empty? + with_default_filter_modes + end + end + end + end +end diff --git a/app/components/primer/open_project/filterable_tree_view.ts b/app/components/primer/open_project/filterable_tree_view.ts new file mode 100644 index 0000000000..a094bcfb54 --- /dev/null +++ b/app/components/primer/open_project/filterable_tree_view.ts @@ -0,0 +1,492 @@ +import {controller, target} from '@github/catalyst' +import {SegmentedControlElement} from '../alpha/segmented_control' +import {TreeViewElement} from './tree_view/tree_view' +import {TreeViewSubTreeNodeElement} from './tree_view/tree_view_sub_tree_node_element' +import {TreeViewNodeInfo} from '../shared_events' + +// This function is expected to return the following values: +// 1. No match - return null +// 2. Match but no highlights - empty array (i.e. when showing all selected nodes but empty query string) +// 3. Match with highlights - non-empty array of Range objects +export type FilterFn = (node: HTMLElement, query: string, filterMode?: string) => Range[] | null + +type NodeState = { + checked: boolean + disabled: boolean +} + +@controller +export class FilterableTreeViewElement extends HTMLElement { + @target filterInput: HTMLInputElement + @target filterModeControlList: HTMLElement + @target treeViewList: HTMLElement + @target noResultsMessage: HTMLElement + @target includeSubItemsCheckBox: HTMLInputElement + + #filterFn?: FilterFn + #abortController: AbortController + #stateMap: Map> = new Map() + + connectedCallback() { + const {signal} = (this.#abortController = new AbortController()) + this.addEventListener('treeViewNodeChecked', this, {signal}) + this.addEventListener('itemActivated', this, {signal}) + this.addEventListener('input', this, {signal}) + } + + disconnectedCallback() { + this.#abortController.abort() + } + + handleEvent(event: Event) { + if (event.target === this.filterModeControl) { + this.#handleFilterModeEvent(event) + } else if (event.target === this.filterInput) { + this.#handleFilterInputEvent(event) + } else if (event.target === this.includeSubItemsCheckBox) { + this.#handleIncludeSubItemsCheckBoxEvent(event) + } else if (event.target instanceof TreeViewElement || event.target instanceof TreeViewSubTreeNodeElement) { + this.#handleTreeViewEvent(event) + } + } + + #handleTreeViewEvent(origEvent: Event) { + const event = origEvent as CustomEvent + + // NOTE: This event only fires if someone actually activates the check mark, i.e. does not fire + // when calling this.treeView.setNodeCheckedValue. + switch (origEvent.type) { + case 'treeViewNodeChecked': + this.#handleTreeViewNodeChecked(event) + break + } + } + + #handleTreeViewNodeChecked(event: CustomEvent) { + if (!this.treeView) return + if (!this.includeSubItemsCheckBox.checked) return + + // Although multiple nodes may have been checked (eg. if the TreeView is in descendants mode), + // the one that actually received the click, i.e. the local root the user checked, is the first + // entry. We only care about sub-tree nodes because checking them affects all leaf nodes, so + // there's no need to check or uncheck individual leaves. + const nodeInfo = event.detail[0] + if (this.treeView.getNodeType(nodeInfo.node) !== 'sub-tree') return + + const subTree = nodeInfo.node.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement + + if (nodeInfo.checkedValue === 'false') { + // If the sub-tree has been unchecked, restore whatever state they were in before. We don't + // need to explicitly enable the sub-tree because restoring will handle setting the enabled + // or disabled state per-node. + this.#restoreNodeState(subTree) + } else { + this.#includeSubItemsUnder(subTree) + } + } + + #restoreNodeState(subTree: TreeViewSubTreeNodeElement) { + if (!this.treeView) return + if (!this.#stateMap.has(subTree)) return + + const descendantStates = this.#stateMap.get(subTree)! + + for (const [element, state] of descendantStates.entries()) { + let node = element + + if (element instanceof TreeViewSubTreeNodeElement) { + node = element.node + } + + this.treeView.setNodeCheckedValue(node, state.checked ? 'true' : 'false') + this.treeView.setNodeDisabledValue(node, state.disabled) + } + + // once node state has been restored, there's no reason to keep it around - it will be saved + // again if this sub-tree gets checked + this.#stateMap.delete(subTree) + } + + get filterModeControl(): SegmentedControlElement | null { + return this.filterModeControlList.closest('segmented-control') + } + + get treeView(): TreeViewElement | null { + return this.treeViewList.closest('tree-view') + } + + #handleFilterModeEvent(event: Event) { + if (event.type !== 'itemActivated') return + + this.#applyFilterOptions() + } + + #handleFilterInputEvent(event: Event) { + if (event.type !== 'input') return + + this.#applyFilterOptions() + } + + #handleIncludeSubItemsCheckBoxEvent(event: Event) { + if (!this.treeView) return + if (event.type !== 'input') return + + this.#applyFilterOptions() + + if (this.includeSubItemsCheckBox.checked) { + this.#includeSubItems() + } else { + this.#restoreAllNodeStates() + } + } + + // Automatically checks all children of checked nodes, including leaf nodes and sub-trees. It does so + // by finding the set of shallowest checked sub-tree nodes, i.e. the set of checked sub-tree nodes with + // the lowest level value. It then saves their node state, disables them, and checks all their children. + // Rather than storing child node information for every checked sub-tree regardless of depth, finding + // the set of shallowest sub-tree nodes allows the component to store the minimum amount of node + // information and simplifies the process of restoring it later. + #includeSubItems() { + if (!this.treeView) return + + for (const subTree of this.treeView.rootSubTreeNodes()) { + for (const checkedSubTree of this.eachShallowestCheckedSubTree(subTree)) { + this.#includeSubItemsUnder(checkedSubTree) + } + } + } + + // Records the state of all the nodes in the given sub-tree. Node state includes whether or not the + // node is checked, and whether or not it is disabled. Or at least, that's what it included when this + // comment was first written. Check the members of the NodeState type above for up-to-date info. + #includeSubItemsUnder(subTree: TreeViewSubTreeNodeElement) { + if (!this.treeView) return + + const descendantStates: Map = new Map() + + for (const node of subTree.eachDescendantNode()) { + descendantStates.set(node as HTMLElement, { + checked: this.treeView.getNodeCheckedValue(node) === 'true', + disabled: this.treeView.getNodeDisabledValue(node), + }) + + this.treeView.setNodeCheckedValue(node, 'true') + this.treeView.setNodeDisabledValue(node, true) + } + + this.#stateMap.set(subTree, descendantStates) + } + + // Revert all nodes back to their saved state, i.e. from before we automatically checked and disabled + // everything. + #restoreAllNodeStates() { + for (const subTree of this.#stateMap.keys()) { + this.#restoreNodeState(subTree) + } + } + + set filterFn(newFn: FilterFn) { + this.#filterFn = newFn + } + + get filterFn(): FilterFn { + if (this.#filterFn) { + return this.#filterFn + } else { + return this.defaultFilterFn + } + } + + defaultFilterFn(node: HTMLElement, query: string, filterMode?: string): Range[] | null { + const ranges = [] + + if (query.length > 0) { + const lowercaseQuery = query.toLowerCase() + const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT) + let currentNode = treeWalker.nextNode() + + while (currentNode) { + const lowercaseNodeText = currentNode.textContent?.toLocaleLowerCase() || '' + let startIndex = 0 + + while (startIndex < lowercaseNodeText.length) { + const index = lowercaseNodeText.indexOf(lowercaseQuery, startIndex) + if (index === -1) break + + const range = new Range() + range.setStart(currentNode, index) + range.setEnd(currentNode, index + lowercaseQuery.length) + ranges.push(range) + + startIndex = index + lowercaseQuery.length + } + + currentNode = treeWalker.nextNode() + } + } + + if (ranges.length === 0 && query.length > 0) { + return null + } + + switch (filterMode) { + case 'selected': { + // Only match nodes that have been checked + if (this.treeView?.getNodeCheckedValue(node) !== 'false') { + return ranges + } + + break + } + + case 'all': { + return ranges + } + } + + return null + } + + get filterMode(): string | null { + const current = this.filterModeControl?.current + + if (current) { + return current.getAttribute('data-name') + } else { + return null + } + } + + get queryString(): string { + return this.filterInput.value + } + + /* This function does quite a bit. It's responsible for showing and hiding nodes that match the filter + * criteria, disabling nodes under certain conditions, and rendering highlights for node text that + * matches the query string. The filter criteria are as follows: + * + * 1. A free-form query string from a text input field. + * 2. A SegmentedControl with two options: + * 1. The "Selected" option causes the component to only show checked nodes, provided they also + * satisfy the other filter criteria described here. + * 2. The "All" option causes the component to show all nodes, provided they also satisfy the other + * filter criteria described here. + * + * Whether or not a node matches is determined by a filter function with a `FilterFn` signature. The + * component defines a default filter function, but a user-defined one can also be provided. The filter + * function is expected to return an array of `Range` objects which #applyFilterOptions uses to highlight + * node text that matches the query string. The default filter function identifies matching node text by + * looking for an exact substring match, operating on a lowercased version of both the query string and + * the node text. For an exact description of the expected return values of the filter function, please + * see the FilterFn type above. + * + * It should be noted that the returned `Range` objects must have starting and ending values that refer + * to offsets inside the same text node. Not adhering to this rule may lead to undefined behavior. + * + * Applying the filter criteria can have the following effects on individual nodes: + * + * 1. Hidden: Nodes are hidden if: + * 1. The filter function returns null. + * 2. Disabled: Nodes are disabled if: + * 1. The node is a child of a checked parent and the "Include sub-items" check box is checked. + * 4. Expanded: Sub-tree nodes are expanded if: + * 1. For at least one of the node's children, including descendants, the filter function returns a + * truthy value. + */ + #applyFilterOptions() { + if (!this.treeView) return + + this.#removeHighlights() + + const query = this.queryString + const mode = this.filterMode || undefined + const generation = window.crypto.randomUUID() + const filterRangesCache: Map = new Map() + + const expandAncestors = (...ancestors: TreeViewSubTreeNodeElement[]) => { + for (const ancestor of ancestors) { + ancestor.expand() + ancestor.removeAttribute('hidden') + ancestor.setAttribute('data-generation', generation) + + if (cachedFilterFn(ancestor.node, query, mode)) { + ancestor.node.removeAttribute('aria-disabled') + } else { + ancestor.node.setAttribute('aria-disabled', 'true') + } + } + } + + // This function is called in the loop below for both leaf nodes and sub-tree nodes to determine + // if they match, and subsequently whether or not to hide them. However, it serves a secondary purpose + // as well in that it remembers the range information returned by the filter function so it can be + // used to highlight matching ranges later. + const cachedFilterFn = (node: HTMLElement, queryStr: string, filterMode?: string): boolean => { + if (!filterRangesCache.has(node)) { + filterRangesCache.set(node, this.filterFn(node, queryStr, filterMode)) + } + + return filterRangesCache.get(node)! !== null + } + + /* We iterate depth-first here in order to be able to examine the most deeply nested leaf nodes + * before their parents. This enables us to easily hide the parent if none of its children match. + * To handle expanding and collapsing ancestors, the algorithm iterates over the provided ancestor + * chain, expanding "upwards" to the root. + * + * Using this technique does mean it's possible to iterate over the same ancestor multiple times. + * For example, consider two nodes that share the same ancestor. Node A contains matching children, + * but node B does not. The algorithm below will visit node A first and expand it and all its + * ancestors. Next, the algorithm will visit node B and collapse all its ancestors. To avoid this, + * the algorithm attaches a random "generation ID" to each node visited. If the generation ID + * matches when visiting a particular node, we know that node has already been visited and should + * not be hidden or collapsed. + */ + for (const [leafNodes, ancestors] of this.eachDescendantDepthFirst(this.treeViewList, 1, [])) { + const parent: TreeViewSubTreeNodeElement | undefined = ancestors[ancestors.length - 1] + let atLeastOneLeafMatches = false + + for (const leafNode of leafNodes) { + if (cachedFilterFn(leafNode, query, mode)) { + leafNode.closest('li')?.removeAttribute('hidden') + atLeastOneLeafMatches = true + } else { + leafNode.closest('li')?.setAttribute('hidden', 'hidden') + } + } + + if (atLeastOneLeafMatches) { + expandAncestors(...ancestors) + } else { + if (parent) { + if (cachedFilterFn(parent.node, query, mode)) { + // sub-tree matched, so expand ancestors + expandAncestors(...ancestors) + } else { + // this node has already been marked by the current generation and is therefore + // a shared ancestor - don't collapse or hide it + if (parent.getAttribute('data-generation') !== generation) { + parent.collapse() + parent.setAttribute('hidden', 'hidden') + } + } + } + } + } + + // convert range map into a 1-dimensional array with no nulls so it can be given to + // #applyHighlights (and therefore CSS.highlights.set) more easily + const allRanges = Array.from(filterRangesCache.values()) + .flat() + .filter(r => r !== null) + + if (allRanges.length === 0 && query.length > 0) { + this.treeViewList.setAttribute('hidden', 'hidden') + this.noResultsMessage.removeAttribute('hidden') + } else { + this.treeViewList.removeAttribute('hidden') + this.noResultsMessage.setAttribute('hidden', 'hidden') + + this.#applyHighlights(allRanges) + } + } + + #applyHighlights(ranges: Range[]) { + // Attempt to use the new-ish custom highlight API: + // https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API + if (CSS.highlights) { + CSS.highlights.set('primer-filterable-tree-view-search-results', new Highlight(...ranges)) + } else { + this.#applyManualHighlights(ranges) + } + } + + #applyManualHighlights(ranges: Range[]) { + const textNode = ranges[0].startContainer + const parent = textNode.parentNode! + const originalText = textNode.textContent! + const fragments = [] + let lastIndex = 0 + + for (const {startOffset, endOffset} of ranges) { + // text before the highlight + if (startOffset > lastIndex) { + fragments.push(document.createTextNode(originalText.slice(lastIndex, startOffset))) + } + + // highlighted text + const mark = document.createElement('mark') + mark.textContent = originalText.slice(startOffset, endOffset) + fragments.push(mark) + + lastIndex = endOffset + } + + // remaining text after the last highlight + if (lastIndex < originalText.length) { + fragments.push(document.createTextNode(originalText.slice(lastIndex))) + } + + // replace original text node with our text + elements + for (const frag of fragments.reverse()) { + parent.insertBefore(frag, textNode.nextSibling) + } + + parent.removeChild(textNode) + } + + #removeHighlights() { + // quick-and-dirty way of ignoring any existing elements and restoring + // the original text + for (const mark of this.querySelectorAll('mark')) { + if (!mark.parentElement) continue + mark.parentElement.replaceChildren(mark.parentElement.textContent!) + } + } + + // Iterates over the nodes in the given sub-tree in depth-first order, yielding a list of leaf nodes + // and an array of ancestor nodes. It uses the aria-level information attached to each node to determine + // the next level of the tree to visit. + *eachDescendantDepthFirst( + node: HTMLElement, + level: number, + ancestry: TreeViewSubTreeNodeElement[], + ): Generator<[NodeListOf, TreeViewSubTreeNodeElement[]]> { + for (const subTreeItem of node.querySelectorAll( + `[role=treeitem][data-node-type='sub-tree'][aria-level='${level}']`, + )) { + const subTree = subTreeItem.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement + yield* this.eachDescendantDepthFirst(subTree, level + 1, [...ancestry, subTree]) + } + + const leafNodes = node.querySelectorAll( + `[role=treeitem][data-node-type='leaf'][aria-level='${level}']`, + ) + + yield [leafNodes, ancestry] + } + + // Yields only the shallowest (i.e. lowest depth) sub-tree nodes that are checked, i.e. does not + // visit a sub-tree's children if that sub-tree is checked. + *eachShallowestCheckedSubTree(root: TreeViewSubTreeNodeElement): Generator { + if (this.treeView?.getNodeCheckedValue(root.node) === 'true') { + yield root + return // do not descend further + } + + for (const childSubTree of root.eachDirectDescendantSubTreeNode()) { + yield* this.eachShallowestCheckedSubTree(childSubTree) + } + } +} + +if (!window.customElements.get('filterable-tree-view')) { + window.FilterableTreeViewElement = FilterableTreeViewElement + window.customElements.define('filterable-tree-view', FilterableTreeViewElement) +} + +declare global { + interface Window { + FilterableTreeViewElement: typeof FilterableTreeViewElement + } +} diff --git a/app/components/primer/open_project/filterable_tree_view/sub_tree.rb b/app/components/primer/open_project/filterable_tree_view/sub_tree.rb new file mode 100644 index 0000000000..bb7112fd2b --- /dev/null +++ b/app/components/primer/open_project/filterable_tree_view/sub_tree.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + class FilterableTreeView + # A `FilterableTreeView` sub-tree node. + # + # This component is part of the <%= link_to_component(Primer::OpenProject::FilterableTreeView) %> component and + # should not be used directly. + class SubTree < Primer::OpenProject::TreeView::SubTree + def with_sub_tree(**system_arguments, &block) + super( + sub_tree_component_klass: self.class, + **system_arguments, + select_variant: :multiple, + select_strategy: :self, + &block + ) + end + + def with_leaf(**system_arguments, &block) + super( + **system_arguments, + select_variant: :multiple, + &block + ) + end + + def with_loading_spinner(**system_arguments) + raise ArgumentError, "FilteredTreeView does not support asynchronous loading" + end + + def with_loading_skeleton(**system_arguments) + raise ArgumentError, "FilteredTreeView does not support asynchronous loading" + end + end + end + end +end diff --git a/app/components/primer/open_project/tree_view.html.erb b/app/components/primer/open_project/tree_view.html.erb index 287b59d9e7..87e67e89bd 100644 --- a/app/components/primer/open_project/tree_view.html.erb +++ b/app/components/primer/open_project/tree_view.html.erb @@ -1,4 +1,8 @@ + <% if acts_as_form_input? %> + <%= @form_arguments[:builder].hidden_field(@form_arguments[:name], multiple: true, skip_default_ids: true, form: "", data: { target: "tree-view.formInputPrototype" }) %> +
+ <% end %> <%= render(Primer::BaseComponent.new(**@system_arguments)) do %> <% nodes.each do |node| %> <%= node %> diff --git a/app/components/primer/open_project/tree_view.pcss b/app/components/primer/open_project/tree_view.pcss index ae5a52c70b..3da45b618b 100644 --- a/app/components/primer/open_project/tree_view.pcss +++ b/app/components/primer/open_project/tree_view.pcss @@ -143,6 +143,10 @@ text-decoration-color: var(--control-fgColor-rest); } + & :has(.TreeViewItemContent[aria-disabled=true]) { + cursor: not-allowed; + } + & .TreeViewItemContent { display: flex; height: 100%; @@ -208,12 +212,56 @@ } } } + + &[aria-disabled="true"] { + pointer-events: none; + + & .TreeViewItemContentText { + color: var(--control-fgColor-disabled); + } + + & .TreeViewItemVisual { + fill: var(--control-fgColor-disabled); + + & svg { + fill: var(--control-fgColor-disabled); + } + } + + & .FormControl-checkbox { + @media (hover: hover) { + &:hover { + cursor: not-allowed; + } + } + } + + @media (hover: hover) { + &:hover { + cursor: not-allowed; + background-color: transparent; + } + } + } + + & ::highlight(primer-filterable-tree-view-search-results) { + background-color: var(--label-yellow-bgColor-active); + color: var(--fgColor-default); + } + + & mark { + background-color: var(--label-yellow-bgColor-active); + color: var(--fgColor-default); + } } & .TreeViewItemContentText { color: var(--control-fgColor-rest); flex: 1 1 auto; width: 0; + /* Do not remove, as otherwise the filter result of the filterableTreeView will not be shown in Safari + see: https://bugs.webkit.org/show_bug.cgi?id=278455 */ + -webkit-user-select: auto; } &:where([data-truncate-text='true']) .TreeViewItemContentText { diff --git a/app/components/primer/open_project/tree_view.rb b/app/components/primer/open_project/tree_view.rb index e61ec24c2a..e4377bdda7 100644 --- a/app/components/primer/open_project/tree_view.rb +++ b/app/components/primer/open_project/tree_view.rb @@ -406,8 +406,9 @@ class TreeView < Primer::Component # @param node_variant [Symbol] The variant to use for this node. <%= one_of(Primer::OpenProject::TreeView::NODE_VARIANT_OPTIONS) %> # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>. - def initialize(node_variant: DEFAULT_NODE_VARIANT, **system_arguments) + def initialize(node_variant: DEFAULT_NODE_VARIANT, form_arguments: {}, **system_arguments) @system_arguments = deny_tag_argument(**system_arguments) + @form_arguments = form_arguments @node_variant = fetch_or_fallback(NODE_VARIANT_OPTIONS, node_variant, DEFAULT_NODE_VARIANT) @@ -419,6 +420,10 @@ def initialize(node_variant: DEFAULT_NODE_VARIANT, **system_arguments) ) end + def acts_as_form_input? + @form_arguments[:builder] && @form_arguments[:name] + end + private def before_render diff --git a/app/components/primer/open_project/tree_view/node.rb b/app/components/primer/open_project/tree_view/node.rb index b45abc0177..449cdea1a9 100644 --- a/app/components/primer/open_project/tree_view/node.rb +++ b/app/components/primer/open_project/tree_view/node.rb @@ -48,6 +48,12 @@ class Node < Primer::Component # @return [Symbol] attr_reader :node_variant + # Whether or not this node is disabled, i.e. cannot be activated. + # + # @return [Boolean] + attr_reader :disabled + alias disabled? disabled + DEFAULT_SELECT_VARIANT = :none SELECT_VARIANT_OPTIONS = [ :multiple, @@ -67,6 +73,8 @@ class Node < Primer::Component # @param current [Boolean] Whether or not this node is the current node. The current node is styled differently than regular nodes and is the first element that receives focus when tabbing to the `TreeView` component. # @param select_variant [Symbol] Controls the type of checkbox that appears. <%= one_of(Primer::OpenProject::TreeView::Node::SELECT_VARIANT_OPTIONS) %> # @param checked [Boolean | String] The checked state of the node's checkbox. <%= one_of(Primer::OpenProject::TreeView::Node::CHECKED_STATES) %> + # @param disabled [Boolean] Whether or not the node can be activated. Passing `false` here will cause the node to appear visually disabled but it is still keyboard-focusable. + # @param value [String] If this node is checked, this value will be sent to the server on form submission. # @param content_arguments [Hash] Arguments attached to the node's content, i.e the `