From 2670c7d82b95d45455900acd97a97b6ce867ec04 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 09:34:43 -0400 Subject: [PATCH 1/4] Establish a nav section for prototypes and a directory to hold them --- src/pattern-library/components/PlaygroundApp.tsx | 13 +++++++++++++ .../components/patterns/prototype/.gitkeep | 0 src/pattern-library/routes.ts | 1 + 3 files changed, 14 insertions(+) create mode 100644 src/pattern-library/components/patterns/prototype/.gitkeep diff --git a/src/pattern-library/components/PlaygroundApp.tsx b/src/pattern-library/components/PlaygroundApp.tsx index 772bdc3b..71da20b3 100644 --- a/src/pattern-library/components/PlaygroundApp.tsx +++ b/src/pattern-library/components/PlaygroundApp.tsx @@ -153,6 +153,8 @@ export default function PlaygroundApp({ ); + const prototypeRoutes = getRoutes('prototype'); + const groupKeys = Object.keys(componentGroups) as Array< keyof typeof componentGroups >; @@ -194,6 +196,17 @@ export default function PlaygroundApp({ ); })} + {prototypeRoutes.length > 0 && ( + <> + Prototypes + + {prototypeRoutes.map(route => ( + + ))} + + + )} + {extraRoutes.length > 0 && ( <> {extraRoutesTitle} diff --git a/src/pattern-library/components/patterns/prototype/.gitkeep b/src/pattern-library/components/patterns/prototype/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index 650aaafb..e10adf34 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -41,6 +41,7 @@ export type PlaygroundRouteGroup = | 'home' | 'foundations' | 'components' + | 'prototype' | 'custom'; /** From 0bd225c5b41c817b64fb27a3a3393f7f460f76e3 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 09:34:43 -0400 Subject: [PATCH 2/4] Add Pin, Info icons to support UI prototyping --- images/icons/info.svg | 4 ++++ images/icons/pin.svg | 1 + src/components/icons/Info.tsx | 26 ++++++++++++++++++++++++++ src/components/icons/Pin.tsx | 32 ++++++++++++++++++++++++++++++++ src/components/icons/index.ts | 2 ++ 5 files changed, 65 insertions(+) create mode 100644 images/icons/info.svg create mode 100644 images/icons/pin.svg create mode 100644 src/components/icons/Info.tsx create mode 100644 src/components/icons/Pin.tsx diff --git a/images/icons/info.svg b/images/icons/info.svg new file mode 100644 index 00000000..634faae0 --- /dev/null +++ b/images/icons/info.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/images/icons/pin.svg b/images/icons/pin.svg new file mode 100644 index 00000000..f2a17513 --- /dev/null +++ b/images/icons/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/icons/Info.tsx b/src/components/icons/Info.tsx new file mode 100644 index 00000000..1274904c --- /dev/null +++ b/src/components/icons/Info.tsx @@ -0,0 +1,26 @@ +// This file was auto-generated using scripts/generate-icons.js +import type { JSX } from 'preact'; + +export type InfoIconProps = JSX.SVGAttributes; + +/** + * Icon generated from info.svg + */ +export default function InfoIcon(props: InfoIconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/Pin.tsx b/src/components/icons/Pin.tsx new file mode 100644 index 00000000..d337cc09 --- /dev/null +++ b/src/components/icons/Pin.tsx @@ -0,0 +1,32 @@ +// This file was auto-generated using scripts/generate-icons.js +import type { JSX } from 'preact'; + +export type PinIconProps = JSX.SVGAttributes; + +/** + * Icon generated from pin.svg + */ +export default function PinIcon(props: PinIconProps) { + return ( + + + + + ); +} diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 818fc660..d888b4ce 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -60,6 +60,7 @@ export { default as HideFilledIcon } from './HideFilled'; export { default as HighlightIcon } from './Highlight'; export { default as ImageIcon } from './Image'; export { default as ImageFilledIcon } from './ImageFilled'; +export { default as InfoIcon } from './Info'; export { default as LeaveIcon } from './Leave'; export { default as LeaveFilledIcon } from './LeaveFilled'; export { default as LinkIcon } from './Link'; @@ -75,6 +76,7 @@ export { default as MenuCollapseIcon } from './MenuCollapse'; export { default as MenuExpandIcon } from './MenuExpand'; export { default as NoteIcon } from './Note'; export { default as NoteFilledIcon } from './NoteFilled'; +export { default as PinIcon } from './Pin'; export { default as PlusIcon } from './Plus'; export { default as PointerDownIcon } from './PointerDown'; export { default as PointerUpIcon } from './PointerUp'; From c6c8e4ea8c4f18a4687db60391efd55c2b914cc7 Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 09:34:43 -0400 Subject: [PATCH 3/4] Copy Menu components from client Bring over a few Menu-related components from the `client` app to support immediate prototyping needs. These were lightly altered to update imports. These are only for prototyping support purposes. --- .../components/patterns/prototype/Menu.tsx | 258 +++++++++++++++ .../patterns/prototype/MenuArrow.tsx | 32 ++ .../patterns/prototype/MenuItem.tsx | 299 ++++++++++++++++++ 3 files changed, 589 insertions(+) create mode 100644 src/pattern-library/components/patterns/prototype/Menu.tsx create mode 100644 src/pattern-library/components/patterns/prototype/MenuArrow.tsx create mode 100644 src/pattern-library/components/patterns/prototype/MenuItem.tsx diff --git a/src/pattern-library/components/patterns/prototype/Menu.tsx b/src/pattern-library/components/patterns/prototype/Menu.tsx new file mode 100644 index 00000000..2e7d8d9c --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/Menu.tsx @@ -0,0 +1,258 @@ +import classnames from 'classnames'; +import type { ComponentChildren } from 'preact'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; + +import { useElementShouldClose } from '../../../../'; +import { MenuExpandIcon } from '../../../../'; +import MenuArrow from './MenuArrow'; + +/** + * Flag indicating whether the next click event on the menu's toggle button + * should be ignored, because the action it would trigger has already been + * triggered by a preceding "mousedown" event. + */ +let ignoreNextClick = false; + +export type MenuProps = { + /** + * Whether the menu content is aligned with the left (default) or right edges + * of the toggle element. + */ + align?: 'left' | 'right'; + + /** + * Additional CSS class for the arrow caret at the edge of the menu content + * that "points" toward the menu's toggle button. This can be used to adjust + * the position of that caret respective to the toggle button. + */ + arrowClass?: string; + + /** + * Label element or string for the toggle button that hides and shows the menu + */ + label: ComponentChildren; + + /** Menu content, typically `MenuSection` and `MenuItem` components */ + children: ComponentChildren; + + /** + * Whether the menu elements should be positioned relative to the Menu + * container. When `false`, the consumer is responsible for positioning. + */ + containerPositioned?: boolean; + + /** Additional CSS classes to apply to the Menu */ + contentClass?: string; + + /** + * Whether the menu is open when initially rendered. Ignored if `open` is + * present. + */ + defaultOpen?: boolean; + + /** Whether to render an (arrow) indicator next to the Menu label */ + menuIndicator?: boolean; + + /** Callback when the Menu is opened or closed. */ + onOpenChanged?: (open: boolean) => void; + + /** + * Whether the Menu is currently open, when the Menu is being used as a + * controlled component. In these cases, an `onOpenChanged` handler should + * be provided to respond to the user opening or closing the menu. + */ + open?: boolean; + + /** + * A title for the menu. This is important for accessibility if the menu's + * toggle button has only an icon as a label. + */ + title: string; +}; + +const noop = () => {}; + +/** + * A drop-down menu. + * + * Menus consist of a button which toggles whether the menu is open, an + * an arrow indicating the state of the menu and content when is shown when + * the menu is open. The children of the menu component are rendered as the + * content of the menu when open. Typically this consists of a list of + * `MenuSection` and/or `MenuItem` components. + * + * @example + * + * + * + * + * + * + * + */ +export default function Menu({ + align = 'left', + arrowClass = '', + children, + containerPositioned = true, + contentClass, + defaultOpen = false, + label, + open, + onOpenChanged, + menuIndicator = true, + title, +}: MenuProps) { + let [isOpen, setOpen]: [boolean, (open: boolean) => void] = + useState(defaultOpen); + if (typeof open === 'boolean') { + isOpen = open; + setOpen = onOpenChanged || noop; + } + + // Notify parent when menu is opened or closed. + const wasOpen = useRef(isOpen); + useEffect(() => { + if (typeof onOpenChanged === 'function' && wasOpen.current !== isOpen) { + wasOpen.current = isOpen; + onOpenChanged(isOpen); + } + }, [isOpen, onOpenChanged]); + + /** + * Toggle menu when user presses toggle button. The menu is shown on mouse + * press for a more responsive/native feel but also handles a click event for + * activation via other input methods. + */ + const toggleMenu = (event: Event) => { + // If the menu was opened on press, don't close it again on the subsequent + // mouse up ("click") event. + if (event.type === 'mousedown') { + ignoreNextClick = true; + } else if (event.type === 'click' && ignoreNextClick) { + // Ignore "click" event triggered from the mouse up action. + ignoreNextClick = false; + event.stopPropagation(); + event.preventDefault(); + return; + } + + setOpen(!isOpen); + }; + const closeMenu = useCallback(() => setOpen(false), [setOpen]); + + // Set up an effect which adds document-level event handlers when the menu + // is open and removes them when the menu is closed or removed. + // + // These handlers close the menu when the user taps or clicks outside the + // menu or presses Escape. + const menuRef = useRef(null); + + // Menu element should close via `closeMenu` whenever it's open and there + // are user interactions outside of it (e.g. clicks) in the document + useElementShouldClose(menuRef, isOpen, closeMenu); + + const stopPropagation = (e: Event) => e.stopPropagation(); + + // It should also close if the user presses a key which activates menu items. + const handleMenuKeyDown = (event: KeyboardEvent) => { + const key = event.key; + if (key === 'Enter' || key === ' ') { + // The browser will not open the link if the link element is removed + // from within the keypress event that triggers it. Add a little + // delay to work around that. + setTimeout(() => { + closeMenu(); + }); + } + }; + + const containerStyle = { + position: containerPositioned ? 'relative' : 'static', + }; + + return ( + // See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
+ + {isOpen && ( + <> + +
+ {children} +
+ + )} +
+ ); +} diff --git a/src/pattern-library/components/patterns/prototype/MenuArrow.tsx b/src/pattern-library/components/patterns/prototype/MenuArrow.tsx new file mode 100644 index 00000000..0473f4c0 --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/MenuArrow.tsx @@ -0,0 +1,32 @@ +import classnames from 'classnames'; + +import { PointerDownIcon, PointerUpIcon } from '../../../../'; + +type MenuArrowProps = { + classes?: string; + direction?: 'up' | 'down'; +}; + +/** + * Render a white-filled "pointer" arrow for use in menus and menu-like + * elements + * + * This will set up absolute positioning for this arrow, but the vertical and + * horizontal positioning will need to be tuned in the component using the + * arrow by adding additional utility classes (`classes` prop here). + */ +export default function MenuArrow({ + classes, + direction = 'up', +}: MenuArrowProps) { + const Icon = direction === 'up' ? PointerUpIcon : PointerDownIcon; + return ( + + ); +} diff --git a/src/pattern-library/components/patterns/prototype/MenuItem.tsx b/src/pattern-library/components/patterns/prototype/MenuItem.tsx new file mode 100644 index 00000000..33ae100b --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/MenuItem.tsx @@ -0,0 +1,299 @@ +import classnames from 'classnames'; +import type { ComponentChildren, Ref } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; + +import { CaretUpIcon, MenuExpandIcon } from '../../../../'; +import type { IconComponent } from '../../../../'; + +type SubmenuToggleProps = { + title: string; + isExpanded: boolean; + onToggleSubmenu?: (e: Event) => void; +}; + +function SubmenuToggle({ + title, + isExpanded, + onToggleSubmenu, +}: SubmenuToggleProps) { + // FIXME: Use `MenuCollapseIcon` instead of `CaretUpIcon` once size + // disparities are addressed + const Icon = isExpanded ? CaretUpIcon : MenuExpandIcon; + return ( +
inside of the menu item itself + // but we have a non-standard mechanism with the toggle control + // requiring an onClick event nested inside a "menuitemradio|menuitem". + // Therefore, a static element with a role="none" is necessary here. + role="none" + className={classnames( + // Center content in a 40px square. The entire element is clickable + 'flex flex-col items-center justify-center w-10 h-10', + 'text-grey-6 bg-grey-1', + // Clip the background (color) such that it only shows within the + // content box, which is a 24px rounded square formed by the large + // borders + 'bg-clip-content border-[8px] border-transparent rounded-xl', + // When the menu item is hovered AND this element is hovered, darken + // the text color so it is clear that the toggle is the hovered element + 'group-hover:hover:text-grey-8', + { + // When the submenu is expanded, this element always has a darker + // background color regardless of hover state. + 'bg-grey-4': isExpanded, + // When the parent menu item is hovered, it gets a darker background. + // Make the toggle background darker also. + 'group-hover:bg-grey-3': !isExpanded, + } + )} + onClick={onToggleSubmenu} + title={title} + > + +
+ ); +} + +export type MenuItemProps = { + /** + * URL of the external link to open when this item is clicked. Either the + * `href` or an `onClick` callback should be supplied. + */ + href?: string; + + /** + * Icon to render for this item. This will show to the left of the item label + * unless this is a submenu item, in which case it goes on the right. Ignored + * if this is not a submenu item and `leftChannelContent` is also provided. + */ + icon?: IconComponent; + + /** + * Dim the label to indicate that this item is not currently available. The + * `onClick` callback will still be invoked when this item is clicked and the + * submenu, if any, can still be toggled. + */ + isDisabled?: boolean; + + /** Indicates that the submenu associated with this item is currently open */ + isExpanded?: boolean; + + /** + * Display an indicator to show that this menu item represents something which + * is currently selected/active/focused. + */ + isSelected?: boolean; + + /** + * True if this item is part of a submenu, in which case it is rendered with a + * different style (shaded background) + */ + isSubmenuItem?: boolean; + + /** + * If present, display a button to toggle the sub-menu associated with this + * item and indicate the current state; `true` if the submenu is visible. + * Note. Omit this prop, or set it to null, if there is no `submenu`. + */ + isSubmenuVisible?: boolean; + + label: ComponentChildren; + + /** + * Optional content to render into a left channel. This accommodates small + * non-icon images or spacing and will supersede any provided icon if this + * is not a submenu item. + */ + leftChannelContent?: ComponentChildren; + + onClick?: (e: Event) => void; + onToggleSubmenu?: (e: Event) => void; + /** + * Contents of the submenu for this item. This is typically a list of + * `MenuItem` components with the `isSubmenuItem` prop set to `true`, but can + * include other content as well. The submenu is only rendered if + * `isSubmenuVisible` is `true`. + */ + submenu?: ComponentChildren; +}; + +/** + * An item in a dropdown menu. + * + * Dropdown menu items display an icon, a label and can optionally have a submenu + * associated with them. + * + * When clicked, menu items either open an external link, if the `href` prop + * is provided, or perform a custom action via the `onClick` callback. + * + * The icon can either be an external SVG image, referenced by URL, or the + * name of an icon registered in the application. + * + * For items that have submenus, the `MenuItem` will call the `renderSubmenu` + * prop to render the content of the submenu, when the submenu is visible. + * Note that the `submenu` is not supported for link (`href`) items. + */ +export default function MenuItem({ + href, + icon: Icon, + isDisabled, + isExpanded, + isSelected, + isSubmenuItem, + isSubmenuVisible, + label, + leftChannelContent, + onClick, + onToggleSubmenu, + submenu, +}: MenuItemProps) { + const menuItemRef = useRef(null); + + let focusTimer: number | undefined; + + // menuItem can be either a link or a button + let menuItem; + const hasSubmenuVisible = typeof isSubmenuVisible === 'boolean'; + const isRadioButtonType = typeof isSelected === 'boolean'; + + useEffect(() => { + return () => { + // unmount + clearTimeout(focusTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowRight': + if (onToggleSubmenu) { + event.stopPropagation(); + event.preventDefault(); + onToggleSubmenu(event); + } + break; + case 'Enter': + case ' ': + if (onClick) { + // Let event propagate so the menu closes + onClick(event); + } + } + }; + + const renderedIcon = Icon ? : null; + const leftIcon = !isSubmenuItem ? renderedIcon : null; + const rightIcon = isSubmenuItem ? renderedIcon : null; + + const hasLeftChannel = leftChannelContent || isSubmenuItem || !!leftIcon; + const hasRightContent = !!rightIcon; + + const menuItemContent = ( + <> + {hasLeftChannel && ( +
+ {leftChannelContent ?? leftIcon} +
+ )} + + {label} + + {hasRightContent && ( +
+ {rightIcon} +
+ )} + {hasSubmenuVisible && ( + + )} + + ); + + const wrapperClasses = classnames( + 'focus-visible-ring ring-inset', + 'w-full min-w-[150px] flex items-center select-none', + 'border-b', + // Set this container as a "group" so that children may style based on its + // layout state. + // See https://tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-parent-state + 'group', + { + 'min-h-[30px] font-normal': isSubmenuItem, + 'min-h-[40px] font-medium': !isSubmenuItem, + 'bg-grey-1 hover:bg-grey-3': isSubmenuItem || isExpanded, + 'bg-white hover:bg-grey-1': !isSubmenuItem && !isExpanded && !isDisabled, + 'bg-grey-0': isDisabled, + // visual "padding" on the right is part of SubmenuToggle when rendered, + // but when not rendering a SubmenuToggle, we need to add some padding here + 'pr-1': !hasSubmenuVisible, + }, + { + // When the item is selected, show a left border to indicate it + 'border-l-[4px] border-l-brand': isSelected, + // Add equivalent padding to border size when not selected. This instead + // of a transparent left border to make focus ring cover the full + // menu item. Otherwise the focus ring will be inset on the left too far. + 'pl-[4px]': !isSelected, + 'border-b-grey-3': isExpanded, + 'border-b-transparent': !isExpanded, + 'text-color-text-light': isDisabled, + 'text-color-text': !isDisabled, + } + ); + + if (href) { + // The menu item is a link + menuItem = ( + } + className={wrapperClasses} + data-testid="menu-item" + href={href} + target="_blank" + tabIndex={-1} + rel="noopener noreferrer" + role="menuitem" + onKeyDown={onKeyDown} + > + {menuItemContent} + + ); + } else { + // The menu item is a clickable button or radio button. + // In either case there may be an optional submenu. + menuItem = ( +
} + className={wrapperClasses} + data-testid="menu-item" + tabIndex={-1} + onKeyDown={onKeyDown} + onClick={onClick} + role={isRadioButtonType ? 'menuitemradio' : 'menuitem'} + aria-checked={isRadioButtonType ? isSelected : undefined} + aria-haspopup={hasSubmenuVisible} + aria-expanded={hasSubmenuVisible ? isSubmenuVisible : undefined} + > + {menuItemContent} +
+ ); + } + return ( + <> + {menuItem} + {hasSubmenuVisible &&
{submenu}
} + + ); +} From 96168c087f7330ea490d4a4fb58206e343d3effb Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Tue, 18 Apr 2023 09:34:43 -0400 Subject: [PATCH 4/4] Prototype UI wireframes for aspects of annotation re-use in LMS --- .../FakeAnnotationPublishControl.tsx | 139 +++++++ .../prototype/SharedAnnotationsPage.tsx | 350 ++++++++++++++++++ src/pattern-library/routes.ts | 7 + 3 files changed, 496 insertions(+) create mode 100644 src/pattern-library/components/patterns/prototype/FakeAnnotationPublishControl.tsx create mode 100644 src/pattern-library/components/patterns/prototype/SharedAnnotationsPage.tsx diff --git a/src/pattern-library/components/patterns/prototype/FakeAnnotationPublishControl.tsx b/src/pattern-library/components/patterns/prototype/FakeAnnotationPublishControl.tsx new file mode 100644 index 00000000..aaa27899 --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/FakeAnnotationPublishControl.tsx @@ -0,0 +1,139 @@ +import classnames from 'classnames'; + +import { + Button, + CancelIcon, + GlobeIcon, + GroupsIcon, + InfoIcon, + LockIcon, + MenuExpandIcon, +} from '../../../..'; +import Menu from './Menu'; +import MenuItem from './MenuItem'; + +export type AnnotationPublishControlProps = { + /** The group this annotation or draft would publish to */ + group: string; + + /** + * Should the save button be disabled? Hint: it will be if the annotation has + * no content + */ + isDisabled?: boolean; + + /** Annotation or draft is "Only Me" */ + isPrivate?: boolean; + + noSharing?: boolean; + + noSharingMessage?: string; + + /** Callback for cancel button click */ + onCancel?: () => void; + + /** Callback for save button click */ + onSave?: () => void; +}; + +/** + * Render a compound control button for publishing (saving) an annotation: + * - Save the annotation — left side of button + * - Choose sharing/privacy option - drop-down menu on right side of button + * + * @param {AnnotationPublishControlProps} props + */ +function AnnotationPublishControl({ + group, + isDisabled = false, + isPrivate = false, + noSharing = false, + noSharingMessage = "Why can't I share this annotation?", + onCancel = () => {}, + onSave = () => {}, +}: AnnotationPublishControlProps) { + const menuLabel = ( +
+ +
+ ); + + return ( +
+
+ + {/* This wrapper div is necessary because of peculiarities with + Safari: see https://github.com/hypothesis/client/issues/2302 */} +
+ + + + {noSharing && ( +
+
+
+ +
+
+ {noSharingMessage} +
+
+
+ )} + +
+
+
+
+ +
+
+ ); +} + +export default AnnotationPublishControl; diff --git a/src/pattern-library/components/patterns/prototype/SharedAnnotationsPage.tsx b/src/pattern-library/components/patterns/prototype/SharedAnnotationsPage.tsx new file mode 100644 index 00000000..25fba7e6 --- /dev/null +++ b/src/pattern-library/components/patterns/prototype/SharedAnnotationsPage.tsx @@ -0,0 +1,350 @@ +import type { ComponentChildren } from 'preact'; +import { useState } from 'preact/hooks'; + +import { + Button, + Card, + CardActions, + CardContent, + Checkbox, + EditIcon, + GroupsFilledIcon, + IconButton, + ModalDialog, + PinIcon, + ReplyIcon, + TrashIcon, + Tab, + TabList, +} from '../../../../'; +import type { ModalDialogProps } from '../../../../components/feedback/ModalDialog'; +import Library from '../../Library'; +import { LoremIpsum } from '../samples'; +import FakeAnnotationPublishControl from './FakeAnnotationPublishControl'; + +type FakeAnnotationProps = { + children?: ComponentChildren; + isOwn?: boolean; + isPinned?: boolean; + isShared?: boolean; +}; + +function FakeAnnotation({ + children, + isOwn = false, + isPinned = false, + isShared = false, +}: FakeAnnotationProps) { + const actions = isOwn ? ['edit', 'delete', 'reply', 'pin'] : ['reply']; + const content = children ?? ; + return ( + +
+ {isPinned && ( +
+ +
+ )} + {isShared && ( +
+ +
+ )} +
+ +
Pretend annotation
+ {content} + +
+ {actions.includes('edit') && ( + + )} + {actions.includes('delete') && ( + + )} + {actions.includes('reply') && ( + + )} + {actions.includes('pin') && ( + + )} +
+
+
+
+ ); +} + +function FakeSidebar({ children }: { children: ComponentChildren }) { + return ( +
+ {children} +
+ ); +} + +/** + * Wrap the ModalDialog component with some state management to make reuse in + * multiple examples plausible and convenient. + */ +function ModalDialog_({ buttons, children, ...dialogProps }: ModalDialogProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const closeDialog = () => setDialogOpen(false); + + const openButton = ( + + ); + + return ( + <> + {!dialogOpen && openButton} + {dialogOpen && ( + + {children} + + )} + + ); +} + +export default function SharedAnnotationsPrototypePage() { + return ( + + {' '} +

+ Give instructors the ability to create and manage{' '} + course-shared content that all + course participants can see regardless of section group membership. + Separately, provide instructors the ability to{' '} + pin content such that it shows up + at the top of sidebar on every tab. +

+

+ On first launch, or when editing settings for an assignment in a + copied course, give the instructor the option to{' '} + + copy all of the course-shared annotations they created in the + source assignment + {' '} + to the copied assignment. +

+ + } + > + +

With this approach context, there are three implied projects:

+ +
    +
  1. Course-shared content
  2. +
  3. Pinned content
  4. +
  5. Annotation re-use for copied course assignments
  6. +
+ +

+ Pinned content is independent functionality and could be + deferred if desired. Annotation re-use depends on{' '} + course-shared content. +

+ + } + /> + + An instructor may create top-level annotations that are visible to + everyone in the course, regardless of which section group they + belong to. An instructor may edit an annotation and move it into or + out of this “all-participants” group. +

+ } + > + + +

+ We might be able to extend the existing annotation-publish + control. The annotation-publish control is available when creating{' '} + or editing an annotation. +

+ + +
+ +
+
+
+ + +

+ It may be difficult technically and logically to deal with moving + annotations between course-shared and section-group-only once they + have replies. If that is the case, we could restrict moving an + annotation once it has replies. +

+ + +
+ +
+
+ + +
+ +
+
+
+
+ + +

+ Content visible to all course participants should be “merged into” + the annotation threads for the active group (section/reading group + as indicated by the group selector in the top bar), but it should be + easy to distinguish which annotation threads are shared to all + participants. +

+ +

+ It should be easy to visually distinguish which annotations in the + sidebar are shared. +

+ + + + Annotations + Page Notes + Orphans + + + + + + + + + +
+
+
+ + + A top-level annotation can be “pinned” by authorized users, which + makes the annotation(s) show up at the top of the sidebar above the + annotation-type tabs at all times. This feature could help with the + use case of instructors wanting to put certain annotations front and + center, or provide instructions or prompts for the assignment as a + whole.{' '} +

+ } + > + +

+ Pinning is a “toggle” type of functionality. We could restrict its + availability to top-level annotations. +

+ +

+ As pinning is a toggling function, we could add an additional icon + to the annotation footer. +

+ + + + + +
+
+ + +

+ Pinned content could be shown above all other content, + including tabs (i.e. a pinned Page Note would also show up on the + Annotations tab, but above the tabs). This could satisfy use cases + relating to creating prompts or instructions for assignments, or for + otherwise showing certain instructor content at the top. +

+ +

An annotation may be both shared and pinned.

+ + + + + + + Annotations + Page Notes + Orphans + + + + + + + +
+
+
+ + + An instructor copies a course. Afterwards, they have the option to{' '} + + copy over their own annotation content that was shared to all + participants + {' '} + on an assignment-by-assignment basis. They could then edit or remove + any that they want to change or delete. +

+ } + > + + + + Continue} + > +

+ It looks like this assignment was copied from another course. + You can re-use your shared content in this assignment. +

+
+ Copy shared annotations to this assignment +
+

+ + Only your annotations shared with all course participants + will be copied. Replies are not copied. You can edit or + remove individual copied annotations at any time. + +

+ + + + + + + ); +} diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index e10adf34..8cd3c30b 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -27,6 +27,7 @@ import LinkButtonPage from './components/patterns/navigation/LinkButtonPage'; import LinkPage from './components/patterns/navigation/LinkPage'; import PointerButtonPage from './components/patterns/navigation/PointerButtonPage'; import TabPage from './components/patterns/navigation/TabPage'; +import SharedAnnotationsPage from './components/patterns/prototype/SharedAnnotationsPage'; export const componentGroups = { data: 'Data Display', @@ -220,6 +221,12 @@ const routes: PlaygroundRoute[] = [ component: TabPage, route: '/navigation-tab', }, + { + title: 'Shared Annotations', + group: 'prototype', + component: SharedAnnotationsPage, + route: '/shared-annotations-ui', + }, ]; /**