Commit 35e5a089 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Add prototype controller and component for highlight clusters

Add a simple controller and component for initializing and updating
styles associated with highlight clusters.
parent 489af4d6
import {
Card,
CardContent,
HideIcon,
} from '@hypothesis/frontend-shared/lib/next';
import classnames from 'classnames';
import { useCallback } from 'preact/hooks';
import type { HighlightCluster } from '../../types/shared';
import type { AppliedStyles, HighlightStyles } from '../highlight-clusters';
type ClusterStyleControlProps = {
cluster: HighlightCluster;
label: string;
onChange: (e: Event) => void;
currentStyles: AppliedStyles;
highlightStyles: HighlightStyles;
};
/**
* Render controls for changing a single highlight cluster's style
*/
function ClusterStyleControl({
cluster,
label,
onChange,
currentStyles,
highlightStyles,
}: ClusterStyleControlProps) {
const appliedStyleName = currentStyles[cluster];
const isHidden = appliedStyleName === 'hidden'; // This style is somewhat special
return (
<div className="space-y-2">
<div className="flex items-center gap-x-2 text-annotator-base">
<div
className="grow text-color-text px-2 py-1 rounded"
style={{
backgroundColor: highlightStyles[appliedStyleName].color,
}}
>
{label}
</div>
</div>
<div className="flex items-center gap-x-2">
{Object.keys(highlightStyles).map(styleName => (
<div className="relative" key={`${cluster}-${styleName}`}>
<input
className={classnames(
// Position this atop its label and size it to the same dimensions
'absolute w-6 h-6',
// Make radio input visually hidden, but
// some screen readers won't read out elements with 0 opacity
'opacity-[.00001]',
'cursor-pointer'
)}
name={cluster}
id={`hypothesis-${cluster}-${styleName}`}
checked={appliedStyleName === styleName}
onChange={onChange}
type="radio"
value={styleName}
/>
<label className="block" htmlFor={`${cluster}-${styleName}`}>
<div
style={{
backgroundColor: highlightStyles[styleName].color,
textDecoration: highlightStyles[styleName].decoration,
}}
className={classnames(
'block w-6 h-6 rounded-full flex items-center justify-center',
{
'border-2 border-slate-0': appliedStyleName !== styleName,
'border-2 border-slate-3': appliedStyleName === styleName,
}
)}
>
{styleName === 'hidden' && (
<HideIcon
className={classnames('w-3 h-3', {
'text-slate-3': !isHidden,
'text-slate-7': isHidden,
})}
/>
)}
</div>
<span className="sr-only">{styleName}</span>
</label>
</div>
))}
</div>
</div>
);
}
export type ClusterToolbarProps = {
/** Is cluster highlight styling active? Do not render the toolbar if not */
active: boolean;
availableStyles: HighlightStyles;
currentStyles: AppliedStyles;
onStyleChange: (cluster: HighlightCluster, styleName: string) => void;
};
/**
* Render controls to change highlight-cluster styling.
*/
export default function ClusterToolbar({
active,
availableStyles,
currentStyles,
onStyleChange,
}: ClusterToolbarProps) {
const handleStyleChange = useCallback(
(changeEvent: Event) => {
const input = changeEvent.target as HTMLInputElement;
const cluster = input.name as HighlightCluster;
const styleName = input.value;
onStyleChange(cluster, styleName);
},
[onStyleChange]
);
if (!active) {
return null;
}
return (
<Card>
<CardContent size="sm">
<ClusterStyleControl
highlightStyles={availableStyles}
label="My annotations"
cluster="user-annotations"
onChange={handleStyleChange}
currentStyles={currentStyles}
/>
<ClusterStyleControl
highlightStyles={availableStyles}
label="My highlights"
cluster="user-highlights"
onChange={handleStyleChange}
currentStyles={currentStyles}
/>
<ClusterStyleControl
highlightStyles={availableStyles}
label="Everybody's content"
cluster="other-content"
onChange={handleStyleChange}
currentStyles={currentStyles}
/>
</CardContent>
</Card>
);
}
import { mount } from 'enzyme';
import { highlightStyles, defaultStyles } from '../../highlight-clusters';
import ClusterToolbar from '../ClusterToolbar';
const noop = () => {};
describe('ClusterToolbar', () => {
const createComponent = props =>
mount(
<ClusterToolbar
active={true}
availableStyles={highlightStyles}
currentStyles={defaultStyles}
onStyleChange={noop}
{...props}
/>
);
it('renders nothing if the cluster feature is not active', () => {
const wrapper = createComponent({ active: false });
assert.isEmpty(wrapper.html());
});
it('renders a control for each highlight cluster', () => {
const wrapper = createComponent();
assert.equal(
wrapper.find('ClusterStyleControl').length,
Object.keys(defaultStyles).length
);
});
it('calls style-change callback when user clicks on a style option', () => {
const onStyleChange = sinon.stub();
const wrapper = createComponent({ onStyleChange });
wrapper
.find('#hypothesis-user-annotations-green')
.getDOMNode()
.dispatchEvent(new Event('change'));
assert.calledOnce(onStyleChange);
assert.calledWith(onStyleChange, 'user-annotations', 'green');
});
});
......@@ -8,6 +8,7 @@ import { matchShortcut } from '../shared/shortcut';
import { Adder } from './adder';
import { TextRange } from './anchoring/text-range';
import { BucketBarClient } from './bucket-bar-client';
import { HighlightClusterController } from './highlight-clusters';
import { FeatureFlags } from './features';
import {
getHighlightsContainingNode,
......@@ -199,6 +200,10 @@ export class Guest {
this.features = new FeatureFlags();
this._clusterToolbar = new HighlightClusterController(element, {
features: this.features,
});
/**
* Integration that handles document-type specific functionality in the
* guest.
......
import { render } from 'preact';
import type {
Destroyable,
FeatureFlags as IFeatureFlags,
} from '../types/annotator';
import type { HighlightCluster } from '../types/shared';
import ClusterToolbar from './components/ClusterToolbar';
import { createShadowRoot } from './util/shadow-root';
export type HighlightStyle = {
color: string;
decoration: string;
};
export type HighlightStyles = Record<string, HighlightStyle>;
export type AppliedStyles = Record<HighlightCluster, keyof HighlightStyles>;
// Available styles that users can apply to highlight clusters
export const highlightStyles: HighlightStyles = {
hidden: {
color: 'transparent',
decoration: 'none',
},
green: {
color: 'var(--hypothesis-color-green)',
decoration: 'none',
},
orange: {
color: 'var(--hypothesis-color-orange)',
decoration: 'none',
},
pink: {
color: 'var(--hypothesis-color-pink)',
decoration: 'none',
},
purple: {
color: 'var(--hypothesis-color-purple)',
decoration: 'none',
},
yellow: {
color: 'var(--hypothesis-color-yellow)',
decoration: 'none',
},
grey: {
color: 'var(--hypothesis-color-grey)',
decoration: 'underline dotted',
},
};
// The default styles applied to each highlight cluster. For now, this is
// hard-coded.
export const defaultStyles: AppliedStyles = {
'other-content': 'yellow',
'user-annotations': 'orange',
'user-highlights': 'purple',
};
export class HighlightClusterController implements Destroyable {
appliedStyles: AppliedStyles;
private _element: HTMLElement;
private _features: IFeatureFlags;
private _outerContainer: HTMLElement;
private _shadowRoot: ShadowRoot;
constructor(element: HTMLElement, options: { features: IFeatureFlags }) {
this._element = element;
this._features = options.features;
this._outerContainer = document.createElement(
'hypothesis-highlight-cluster-toolbar'
);
this._element.appendChild(this._outerContainer);
this._shadowRoot = createShadowRoot(this._outerContainer);
// For now, the controls are fixed at top-left of screen. This is temporary.
Object.assign(this._outerContainer.style, {
position: 'fixed',
top: 0,
left: 0,
});
this.appliedStyles = defaultStyles;
this._init();
this._features.on('flagsChanged', () => {
this._activate(this._isActive());
});
this._render();
}
destroy() {
render(null, this._shadowRoot); // unload the Preact component
this._activate(false); // De-activate cluster styling
this._outerContainer.remove();
}
/**
* Set initial values for :root CSS custom properties (variables) based on the
* applied styles for each cluster. This has no effect if this feature
* is not active.
*/
_init() {
for (const cluster of Object.keys(this.appliedStyles) as Array<
keyof typeof this.appliedStyles
>) {
this._setClusterStyle(cluster, this.appliedStyles[cluster]);
}
this._activate(this._isActive());
}
_isActive() {
return this._features.flagEnabled('styled_highlight_clusters');
}
/**
* Activate cluster highlighting if `active` is set.
*/
_activate(active: boolean) {
this._element.classList.toggle('hypothesis-highlights-clustered', active);
this._render();
}
/**
* Set CSS variables for the highlight `cluster` to apply the
* {@link HighlightStyle} `highlightStyles[styleName]`
*/
_setClusterStyle(
cluster: HighlightCluster,
styleName: keyof typeof highlightStyles
) {
const styleRules = highlightStyles[styleName];
for (const ruleName of Object.keys(styleRules) as Array<
keyof HighlightStyle
>) {
document.documentElement.style.setProperty(
`--hypothesis-${cluster}-${ruleName}`,
styleRules[ruleName]
);
}
}
/**
* Respond to user input to change the applied style for a cluster
*/
_onChangeClusterStyle(
cluster: HighlightCluster,
styleName: keyof typeof highlightStyles
) {
this.appliedStyles[cluster] = styleName;
this._setClusterStyle(cluster, styleName);
this._render();
}
_render() {
render(
<ClusterToolbar
active={this._isActive()}
availableStyles={highlightStyles}
currentStyles={this.appliedStyles}
onStyleChange={(cluster, styleName) =>
this._onChangeClusterStyle(cluster, styleName)
}
/>,
this._shadowRoot
);
}
}
import { waitFor } from '../../test-util/wait';
import { HighlightClusterController, $imports } from '../highlight-clusters';
import { FeatureFlags } from '../features';
describe('HighlightClusterController', () => {
let fakeFeatures;
let fakeSetProperty;
let toolbarProps;
let container;
let controllers;
const createToolbar = options => {
const controller = new HighlightClusterController(container, {
features: fakeFeatures,
...options,
});
controllers.push(controller);
return controller;
};
beforeEach(() => {
controllers = [];
fakeFeatures = new FeatureFlags();
fakeSetProperty = sinon.stub(document.documentElement.style, 'setProperty');
container = document.createElement('div');
toolbarProps = {};
const FakeToolbar = props => {
toolbarProps = props;
return <div style={{ width: '150px' }} />;
};
$imports.$mock({
'./components/ClusterToolbar': FakeToolbar,
});
});
afterEach(() => {
fakeSetProperty.restore();
$imports.$restore();
container.remove();
controllers.forEach(controller => controller.destroy());
});
it('adds an element to the container to hold the toolbar component', () => {
createToolbar();
assert.equal(
container.getElementsByTagName('hypothesis-highlight-cluster-toolbar')
.length,
1
);
assert.isFalse(toolbarProps.active);
});
it('initializes root CSS variables for highlight clusters', () => {
const toolbar = createToolbar();
// Properties should be set for each cluster (keys of `toolbar.appliedStyles`)
// Each cluster has two properties (variables) to be set
const expectedCount = Object.keys(toolbar.appliedStyles).length * 2;
assert.equal(fakeSetProperty.callCount, expectedCount);
});
it('does not activate the feature if feature flag is not set', () => {
createToolbar();
assert.isFalse(
container.classList.contains('hypothesis-highlights-clustered')
);
});
it('activates the feature when the feature flag is set', async () => {
createToolbar();
fakeFeatures.update({ styled_highlight_clusters: true });
await waitFor(() => {
return (
container.classList.contains('hypothesis-highlights-clustered') &&
toolbarProps.active === true
);
});
});
it('deactivates the feature when the feature flag is unset', async () => {
fakeFeatures.update({ styled_highlight_clusters: true });
createToolbar();
assert.isTrue(
container.classList.contains('hypothesis-highlights-clustered')
);
fakeFeatures.update({ styled_highlight_clusters: false });
await waitFor(() => {
return (
!container.classList.contains('hypothesis-highlights-clustered') &&
toolbarProps.active === false
);
});
});
it('responds to toolbar callback to update styles for a highlight cluster', () => {
fakeFeatures.update({ styled_highlight_clusters: true });
createToolbar();
fakeSetProperty.resetHistory();
toolbarProps.onStyleChange('user-highlights', 'green');
assert.equal(fakeSetProperty.callCount, 2);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment