From 59ba84a4586c221e8d12fd434e7f91dd125eb0c1 Mon Sep 17 00:00:00 2001 From: mat Date: Mon, 25 Nov 2024 16:34:13 -0500 Subject: [PATCH] Add language options to Scroll component. (#237) * Add language popover. * Add scroll language detection. * Update docs. --- package-lock.json | 102 ++++++++++++++++++ package.json | 3 +- pages/docs/scroll.mdx | 41 +++++-- pages/docs/scroll/demo.mdx | 11 +- .../Scroll/Annotation/Body.styled.tsx | 8 ++ src/components/Scroll/Annotation/Body.tsx | 25 ++--- src/components/Scroll/Items/Item.tsx | 9 +- .../Scroll/Layout/Layout.styled.tsx | 11 +- .../Scroll/Panel/Language/Language.styled.tsx | 76 +++++++++++++ .../Scroll/Panel/Language/Language.tsx | 55 ++++++++++ .../Scroll/Panel/Language/Option.tsx | 58 ++++++++++ src/components/Scroll/Panel/Panel.tsx | 20 ++-- .../Scroll/Panel/Search/Search.styled.tsx | 3 +- src/components/Scroll/Panel/Search/Search.tsx | 5 +- src/components/Scroll/index.tsx | 12 ++- src/components/UI/Popover/Popover.tsx | 2 +- src/context/scroll-context.tsx | 35 +++++- src/lib/annotation-helpers.ts | 23 +++- 18 files changed, 450 insertions(+), 49 deletions(-) create mode 100644 src/components/Scroll/Panel/Language/Language.styled.tsx create mode 100644 src/components/Scroll/Panel/Language/Language.tsx create mode 100644 src/components/Scroll/Panel/Language/Option.tsx diff --git a/package-lock.json b/package-lock.json index 8e2c7e782..02a498f7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@iiif/parser": "^2.1.4", "@nulib/use-markdown": "^0.2.2", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-popover": "^1.1.2", @@ -2870,6 +2871,107 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", + "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", diff --git a/package.json b/package.json index bc19d34bb..9e626d5bb 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@iiif/parser": "^2.1.4", "@nulib/use-markdown": "^0.2.2", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-popover": "^1.1.2", @@ -95,7 +96,7 @@ "react-error-boundary": "^4.1.2", "react-i18next": "^15.1.1", "sanitize-html": "^2.13.1", - "swiper": "^9.4.1", + "swiper": "^9.0.0", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/pages/docs/scroll.mdx b/pages/docs/scroll.mdx index 5eb59223a..34cb385d9 100644 --- a/pages/docs/scroll.mdx +++ b/pages/docs/scroll.mdx @@ -102,12 +102,13 @@ const MyCustomScroll = () => { `Scroll` can configured through an `options` prop, which will serve as a object for common options. -| Prop | Type | Required | Default | -| ---------------- | --------------------- | -------- | ------- | -| `iiifContent` | `string` | Yes | | -| `options` | `object` | No | | -| `options.offset` | `number` | No | `0` | -| `options.figure` | [See Figure](#figure) | No | | +| Prop | Type | Required | Default | +| ------------------ | ------------------------- | -------- | ------- | +| `iiifContent` | `string` | Yes | | +| `options` | `object` | No | | +| `options.offset` | `number` | No | `0` | +| `options.figure` | [See Figure](#figure) | No | | +| `options.language` | [See Language](#language) | No | | ### Offset @@ -142,3 +143,31 @@ The Scroll component renders a `figure` element for each Canvas. The `options.fi }} /> ``` + +### Language + +The `options.language` object allows for the configuration of the language options for the Scroll component. This includes the default languages and language options. + +By default, the `options.language` object is not set, and the Scroll component will not display any language options. When the `options.language` object is set, the Scroll component will display a language dropdown that allows users to filter the content by languages within the Manifest annotation body resources. If defaultLanguages are not set, the Scroll component will display all languages available in the Manifest. + +The `options.language.options` array should be an array of objects with key-value pairs where the key is the language code and the value is the language name. The language code should match the language code in the Manifest annotation body resources. + +| Prop | Type | Required | Default | +| ----------------------------------- | ------------------------------ | -------- | ------- | +| `options.language.defaultLanguages` | `string[]` | No | `[]` | +| `options.language.enabled` | `boolean` | Yes | `false` | +| `options.language.options` | `Array<[key: string]: string>` | No | `[]` | + +```jsx + +``` diff --git a/pages/docs/scroll/demo.mdx b/pages/docs/scroll/demo.mdx index da162ba96..442489976 100644 --- a/pages/docs/scroll/demo.mdx +++ b/pages/docs/scroll/demo.mdx @@ -16,6 +16,13 @@ A UI component rendering vertical scrolling articles that output individual Canv --- diff --git a/src/components/Scroll/Annotation/Body.styled.tsx b/src/components/Scroll/Annotation/Body.styled.tsx index 283688785..ac866481b 100644 --- a/src/components/Scroll/Annotation/Body.styled.tsx +++ b/src/components/Scroll/Annotation/Body.styled.tsx @@ -3,10 +3,18 @@ import { styled } from "src/styles/stitches.config"; const highlightColor = "255, 197, 32"; // #FFC520 const TextualBody = styled("div", { + opacity: "1", + "&[dir=rtl]": { textAlign: "right", }, + "&[data-active-language=false]": { + opacity: "0", + width: "0", + height: "0", + }, + ul: { padding: "1rem", }, diff --git a/src/components/Scroll/Annotation/Body.tsx b/src/components/Scroll/Annotation/Body.tsx index a8c7f2ab1..39df072b9 100644 --- a/src/components/Scroll/Annotation/Body.tsx +++ b/src/components/Scroll/Annotation/Body.tsx @@ -18,7 +18,7 @@ const ScrollAnnotationBody = ({ type?: string; }) => { const { state } = useContext(ScrollContext); - const { searchActiveMatch, searchString } = state; + const { activeLanguages, searchActiveMatch, searchString } = state; let value = String(body.value); @@ -65,6 +65,7 @@ const ScrollAnnotationBody = ({ const isRtl = ["ar"].includes(String(body.language)); const dir = isRtl ? "rtl" : "ltr"; const fontSize = isRtl ? "1.3em" : "1em"; + const lang = String(body.language); useEffect(() => { // Scroll to the active match @@ -84,17 +85,17 @@ const ScrollAnnotationBody = ({ if (!innerHtml) return null; return ( - <> - - + ); }; diff --git a/src/components/Scroll/Items/Item.tsx b/src/components/Scroll/Items/Item.tsx index 64f361aed..491e42ac1 100644 --- a/src/components/Scroll/Items/Item.tsx +++ b/src/components/Scroll/Items/Item.tsx @@ -33,21 +33,18 @@ const ScrollItem: React.FC = ({ itemNumber, }) => { const { state } = React.useContext(ScrollContext); - const { annotations, vault, options } = state; + const { activeLanguages, annotations, vault, options } = state; const { figure } = options; const canvas = vault?.get(item) as CanvasNormalized; - const numItems = annotations?.filter( - // @ts-ignore - (annotation) => annotation.target?.source?.id === item.id, - ).length; + const numItems = activeLanguages?.length; const annotationBody = annotations // @ts-ignore ?.filter((annotation) => annotation.target?.source?.id === item.id) ?.map((annotation) => { - return annotation?.body?.map((body, index) => ( + return annotation?.body.map((body, index) => ( label": { + fontSize: "0.8333rem", + display: "flex", + marginBottom: "0.5rem", + }, + }, +}); + +const StyledScrollLanguageOptionCheckbox = styled("div", { + width: "1rem", + height: "1rem", + borderRadius: "3px", + backgroundColor: "$secondaryMuted", + border: "1px solid $secondaryAlt", + display: "inline-flex", + fontSize: "0.7222rem", + alignContent: "center", + justifyContent: "center", + textAlign: "center", + flexDirection: "column", +}); + +const StyledScrollLanguageOptionIndicator = styled(Checkbox.Indicator, { + marginTop: "-1px", +}); + +const StyledScrollLanguageOption = styled(Checkbox.Root, { + display: "flex", + alignContent: "center", + alignItems: "center", + gap: "0.5rem", + + "&[data-state='checked']": { + [`${StyledScrollLanguageOptionCheckbox}`]: { + backgroundColor: "$accent", + borderColor: "$accent", + color: "$secondary", + }, + }, +}); + +export { + StyledScrollLanguage, + StyledScrollLanguageOption, + StyledScrollLanguageOptionCheckbox, + StyledScrollLanguageOptionIndicator, +}; diff --git a/src/components/Scroll/Panel/Language/Language.tsx b/src/components/Scroll/Panel/Language/Language.tsx new file mode 100644 index 000000000..eba12669f --- /dev/null +++ b/src/components/Scroll/Panel/Language/Language.tsx @@ -0,0 +1,55 @@ +import LanguageOption from "./Option"; +import { Popover } from "src/components/UI"; +import { ScrollContext } from "src/context/scroll-context"; +import { StyledScrollLanguage } from "./Language.styled"; +import { extractLanguages } from "src/lib/annotation-helpers"; +import { useContext } from "react"; + +const LanguageIcon = ({ + title, + style = {}, +}: { + title: string; + style?: React.CSSProperties; +}) => { + return ( + + {title} + + + ); +}; + +const ScrollLanguage = () => { + const { state } = useContext(ScrollContext); + const { activeLanguages, annotations } = state; + + const languages = annotations + ? (extractLanguages(annotations) as string[]) + : []; + + return ( + + + + + + + + {languages.map((lang) => ( + + ))} + + + + ); +}; + +export default ScrollLanguage; diff --git a/src/components/Scroll/Panel/Language/Option.tsx b/src/components/Scroll/Panel/Language/Option.tsx new file mode 100644 index 000000000..fd63c7340 --- /dev/null +++ b/src/components/Scroll/Panel/Language/Option.tsx @@ -0,0 +1,58 @@ +import { + StyledScrollLanguageOption, + StyledScrollLanguageOptionCheckbox, + StyledScrollLanguageOptionIndicator, +} from "./Language.styled"; + +import { ScrollContext } from "src/context/scroll-context"; +import { useContext } from "react"; + +const LanguageOption = ({ + lang, + isChecked, +}: { + lang: string; + isChecked?: boolean; +}) => { + const { state, dispatch } = useContext(ScrollContext); + const { activeLanguages, options } = state; + const { language } = options; + + // check if lang set in language.options + const hasOption = + language?.options?.find((option) => Object.keys(option)[0] === lang) || + lang; + + // set label + const label = hasOption[lang] || lang; + + const handleCheckedChange = (checked: boolean) => { + const updatedLanguages = + checked && activeLanguages !== undefined + ? [...activeLanguages, lang] + : activeLanguages?.filter((language) => language !== lang); + + dispatch({ + type: "updateActiveLanguages", + payload: updatedLanguages, + }); + }; + + // console.log({ isChecked, lang }); + + return ( + + + + ✓ + + + {label} + + ); +}; + +export default LanguageOption; diff --git a/src/components/Scroll/Panel/Panel.tsx b/src/components/Scroll/Panel/Panel.tsx index c168402c2..94dd61fd4 100644 --- a/src/components/Scroll/Panel/Panel.tsx +++ b/src/components/Scroll/Panel/Panel.tsx @@ -1,10 +1,11 @@ import React, { CSSProperties, useContext, useRef } from "react"; import { - StyledScrollFixed, StyledScrollPanel, + StyledScrollSearch, } from "src/components/Scroll/Layout/Layout.styled"; import { ScrollContext } from "src/context/scroll-context"; +import ScrollLanguage from "./Language/Language"; import ScrollSearchResults from "src/components/Scroll/Panel/Search/Search"; import SearchForm from "src/components/Scroll/Panel/Search/Form"; import { StyledPanel } from "src/components/Scroll/Panel/Panel.styled"; @@ -15,7 +16,7 @@ const ScrollPanel = ({ width, isFixed }) => { const { state } = useContext(ScrollContext); const { options } = state; - const { offset } = options; + const { offset, language } = options; const fixedStyles: CSSProperties = isFixed ? { @@ -28,26 +29,31 @@ const ScrollPanel = ({ width, isFixed }) => { setPanelExpanded(e); } + const languageEnabled = language?.enabled; + const controlsWidth = languageEnabled ? 4.5 : 2; + return ( - + {!isPanelExpanded && languageEnabled && } + { > {isPanelExpanded && } - + ); }; diff --git a/src/components/Scroll/Panel/Search/Search.styled.tsx b/src/components/Scroll/Panel/Search/Search.styled.tsx index a3c845c75..f1d051fb4 100644 --- a/src/components/Scroll/Panel/Search/Search.styled.tsx +++ b/src/components/Scroll/Panel/Search/Search.styled.tsx @@ -29,7 +29,7 @@ const StyledSearchAnnotations = styled("div", { fontSize: "0.9rem", lineHeight: "1.1rem", textAlign: "left", - borderRadius: "6px", + borderRadius: "2rem", border: "1px solid #6662", display: "flex", flexDirection: "column", @@ -135,6 +135,7 @@ const StyledSearchForm = styled("form", { false: { "&:hover": { backgroundColor: "$accent !important", + borderRadius: "2rem", }, [`${StyledSearchIcon}`]: { cursor: "pointer" }, diff --git a/src/components/Scroll/Panel/Search/Search.tsx b/src/components/Scroll/Panel/Search/Search.tsx index e4a5010eb..fa6964b99 100644 --- a/src/components/Scroll/Panel/Search/Search.tsx +++ b/src/components/Scroll/Panel/Search/Search.tsx @@ -46,12 +46,15 @@ const config: IndexOptionsForDocumentSearch<{ const ScrollSearch = () => { const [activeIndex, setActiveIndex] = useState(0); const { dispatch, state } = useContext(ScrollContext); - const { annotations, searchString = "" } = state; + const { activeLanguages, annotations, searchString = "" } = state; const index = new FlexSearch.Document(config); const indexIds: string[] = []; annotations?.forEach((annotation) => { annotation?.body?.forEach((body) => { + // @ts-expect-error + if (!activeLanguages?.includes(String(body.language))) return; + // @ts-expect-error const content = body?.value?.replace(/\n/g, ""); indexIds.push(body?.id); diff --git a/src/components/Scroll/index.tsx b/src/components/Scroll/index.tsx index 267af9fb3..8e21b58a3 100644 --- a/src/components/Scroll/index.tsx +++ b/src/components/Scroll/index.tsx @@ -5,6 +5,7 @@ import { ManifestNormalized } from "@iiif/presentation-3"; import ScrollHeader from "src/components/Scroll/Layout/Header"; import ScrollItems from "src/components/Scroll/Items/Items"; import { StyledScrollWrapper } from "src/components/Scroll/Layout/Layout.styled"; +import { extractLanguages } from "src/lib/annotation-helpers"; import useManifestAnnotations from "src/hooks/useManifestAnnotations"; export interface CloverScrollProps { @@ -16,7 +17,7 @@ const RenderCloverScroll = ({ iiifContent }: { iiifContent: string }) => { const [manifest, setManifest] = useState(); const { state, dispatch } = useContext(ScrollContext); - const { vault } = state; + const { options, vault } = state; const annotations = useManifestAnnotations(manifest?.items, vault); @@ -32,10 +33,19 @@ const RenderCloverScroll = ({ iiifContent }: { iiifContent: string }) => { }, [iiifContent]); useEffect(() => { + const activeLanguages = + options?.language?.defaultLanguages || extractLanguages(annotations); + + console.log(annotations); + dispatch({ type: "updateAnnotations", payload: annotations, }); + dispatch({ + type: "updateActiveLanguages", + payload: activeLanguages, + }); }, [annotations]); if (!manifest) return null; diff --git a/src/components/UI/Popover/Popover.tsx b/src/components/UI/Popover/Popover.tsx index 83fd980fd..7d788cbaf 100644 --- a/src/components/UI/Popover/Popover.tsx +++ b/src/components/UI/Popover/Popover.tsx @@ -41,7 +41,7 @@ type ContentProps = ContentComponentProps & const Content: React.FC = (props) => { return ( - + diff --git a/src/context/scroll-context.tsx b/src/context/scroll-context.tsx index 11075b2a6..87e25f9d7 100644 --- a/src/context/scroll-context.tsx +++ b/src/context/scroll-context.tsx @@ -3,7 +3,12 @@ import React, { Dispatch, createContext, useReducer } from "react"; import { Vault } from "@iiif/helpers/vault"; +type LanguageOption = { + [key: string]: string; // Keys and values are dynamically defined +}; + interface StateType { + activeLanguages?: string[]; annotations?: AnnotationNormalized[]; manifest?: ManifestNormalized; options: { @@ -13,6 +18,11 @@ interface StateType { aspectRatio?: number; width?: CSSStyleDeclaration["width"]; }; + language?: { + defaultLanguages?: string[]; + enabled: boolean; + options?: LanguageOption[]; + }; }; searchActiveMatch?: string; searchMatches?: { @@ -31,6 +41,7 @@ interface ActionType { } export const initialState: StateType = { + activeLanguages: undefined, annotations: [], manifest: undefined, options: { @@ -40,6 +51,11 @@ export const initialState: StateType = { aspectRatio: 100 / 61.8, // golden ratio width: "38.2%", }, + language: { + defaultLanguages: undefined, + enabled: false, + options: [], + }, }, searchActiveMatch: undefined, searchMatches: undefined, @@ -54,6 +70,11 @@ function reducer(state: StateType, action: ActionType): StateType { ...state, annotations: action.payload, }; + case "updateActiveLanguages": + return { + ...state, + activeLanguages: action.payload, + }; case "updateSearchActiveMatch": return { ...state, @@ -83,12 +104,11 @@ export const ScrollContext = createContext<{ }); interface ScrollProviderProps { + activeLanguages?: string[]; annotations?: AnnotationNormalized[]; children: React.ReactNode; manifest?: ManifestNormalized; - options?: { - offset?: number; - }; + options?: StateType["options"]; vault?: Vault; } @@ -99,7 +119,14 @@ export const ScrollProvider: React.FC = (props) => { ...props.options, }; - const [state, dispatch] = useReducer(reducer, initialState); + // Dynamically set the initial activeLanguages based on options.language.defaultLanguages + const initialActiveLanguages = options.language?.defaultLanguages || []; + + const [state, dispatch] = useReducer(reducer, { + ...initialState, + activeLanguages: initialActiveLanguages, + options, + }); return ( { return parsedTarget; }; -export { parseAnnotationTarget }; +function extractLanguages(annotations: AnnotationNormalized[]) { + const languages = new Set(); + + function findLanguage(obj) { + if (Array.isArray(obj)) { + obj.forEach(findLanguage); + } else if (obj && typeof obj === "object") { + if (obj.language) { + languages.add(obj.language); + } + Object.values(obj).forEach(findLanguage); + } + } + + findLanguage(annotations); + return Array.from(languages); +} + +export { extractLanguages, parseAnnotationTarget };