Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .yarn/versions/c9eaa193.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
releases:
"@interop-ui/react-checkbox": prerelease
"@interop-ui/react-label": prerelease
"@interop-ui/react-radio-group": prerelease
"@interop-ui/react-switch": prerelease
"@interop-ui/react-utils": prerelease

declined:
- interop-ui
- "@interop-ui/popper"
- "@interop-ui/react-accessible-icon"
- "@interop-ui/react-accordion"
- "@interop-ui/react-alert-dialog"
- "@interop-ui/react-announce"
- "@interop-ui/react-arrow"
- "@interop-ui/react-aspect-ratio"
- "@interop-ui/react-avatar"
- "@interop-ui/react-collapsible"
- "@interop-ui/react-collection"
- "@interop-ui/react-debug-context"
- "@interop-ui/react-dialog"
- "@interop-ui/react-lock"
- "@interop-ui/react-lock-modular-temp"
- "@interop-ui/react-popover"
- "@interop-ui/react-popper"
- "@interop-ui/react-portal"
- "@interop-ui/react-progress-bar"
- "@interop-ui/react-separator"
- "@interop-ui/react-sheet"
- "@interop-ui/react-slider"
- "@interop-ui/react-tabs"
- "@interop-ui/react-toggle-button"
- "@interop-ui/react-tooltip"
- "@interop-ui/react-use-size"
- "@interop-ui/react-visually-hidden"
- "@interop-ui/docs"
16 changes: 5 additions & 11 deletions packages/react/checkbox/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ const Checkbox = forwardRef<typeof CHECKBOX_DEFAULT_TAG, CheckboxProps, Checkbox
onCheckedChange,
...checkboxProps
} = props;
const labelId = useLabelContext();
const labelledBy = ariaLabelledby || labelId;
const inputRef = React.useRef<HTMLInputElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const ref = useComposedRefs(forwardedRef, buttonRef);
const labelId = useLabelContext(buttonRef);
const labelledBy = ariaLabelledby || labelId;
const [checked = false, setChecked] = useControlledState({
prop: checkedProp,
defaultProp: defaultChecked,
Expand Down Expand Up @@ -84,14 +84,6 @@ const Checkbox = forwardRef<typeof CHECKBOX_DEFAULT_TAG, CheckboxProps, Checkbox
hidden
onChange={composeEventHandlers(onCheckedChange, (event) => {
setChecked(event.target.checked);
/**
* When this component is wrapped in a label, clicking the label
* will not focus the button (but it will correctly trigger the input)
* so we manually focus it.
*/
if (buttonRef.current?.ownerDocument.activeElement !== buttonRef.current) {
buttonRef.current?.focus();
}
})}
/>
<Comp
Expand All @@ -111,7 +103,9 @@ const Checkbox = forwardRef<typeof CHECKBOX_DEFAULT_TAG, CheckboxProps, Checkbox
* The `input` is hidden, so when the button is clicked we trigger
* the input manually
*/
onClick={() => inputRef.current?.click()}
onClick={composeEventHandlers(props.onClick, () => inputRef.current?.click(), {
checkForDefaultPrevented: false,
})}
>
<CheckboxContext.Provider value={checked}>{children}</CheckboxContext.Provider>
</Comp>
Expand Down
15 changes: 11 additions & 4 deletions packages/react/label/src/Label.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@ export const SuppliedId = () => (
</Label>
);

export const WithHtmlFor = () => (
<>
<Label htmlFor="test">This should add an `aria-labelledby` to the control</Label>
<Control id="test" />
</>
);

/* -------------------------------------------------------------------------------------------------
* Control
* -----------------------------------------------------------------------------------------------*/

const Control = () => {
const Control = (props: any) => {
const id = useLabelContext();
return <span>{id}</span>;
return <span {...props}>{id}</span>;
};

/* -------------------------------------------------------------------------------------------------
Expand All @@ -33,12 +40,12 @@ const Control = () => {

const Root = React.forwardRef<HTMLLabelElement, React.ComponentProps<typeof Label>>(
({ children, ...props }, forwardedRef) => (
<label
<span
{...props}
style={{ ...styles.root, border: '1px solid gainsboro', margin: 10, padding: 10 }}
ref={forwardedRef}
>
{children}
</label>
</span>
)
);
94 changes: 82 additions & 12 deletions packages/react/label/src/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,105 @@ import { createStyleObj, forwardRef, useId, useComposedRefs } from '@interop-ui/
* -----------------------------------------------------------------------------------------------*/

const LABEL_NAME = 'Label';
const LABEL_DEFAULT_TAG = 'label';
const LABEL_DEFAULT_TAG = 'span';

type LabelDOMProps = React.ComponentPropsWithoutRef<typeof LABEL_DEFAULT_TAG>;
type LabelOwnProps = {};
type LabelOwnProps = { htmlFor?: string };
type LabelProps = LabelOwnProps & LabelDOMProps;

const LabelContext = React.createContext<string | undefined>(undefined);
const useLabelContext = () => React.useContext(LabelContext);
type LabelContextValue = { id: string; ref: React.RefObject<HTMLSpanElement> };
const LabelContext = React.createContext<LabelContextValue | undefined>(undefined);

const useLabelContext = <E extends HTMLElement>(ref?: React.RefObject<E>) => {
const context = React.useContext(LabelContext);

React.useEffect(() => {
const label = context?.ref.current;
const element = ref?.current;

if (label && element) {
return addLabelClickEventListener(label, element);
}
}, [context, ref]);

return context?.id;
};

const Label = forwardRef<typeof LABEL_DEFAULT_TAG, LabelProps>(function Label(props, forwardedRef) {
const { as: Comp = LABEL_DEFAULT_TAG, id: idProp, children, ...labelProps } = props;
const labelRef = React.useRef<HTMLLabelElement>(null);
const { htmlFor, as: Comp = LABEL_DEFAULT_TAG, id: idProp, children, ...labelProps } = props;
const labelRef = React.useRef<HTMLSpanElement>(null);
const ref = useComposedRefs(forwardedRef, labelRef);
const generatedId = `label-${useId()}`;
const id = idProp || generatedId;

React.useEffect(() => {
// prevent text selection when double clicking label
labelRef.current?.addEventListener('mousedown', (event) => {
if (event.detail > 1) event.preventDefault();
});
const label = labelRef.current;

if (label) {
const handleMouseDown = (event: MouseEvent) => {
if (event.detail > 1) event.preventDefault();
};

// prevent text selection when double clicking label
label.addEventListener('mousedown', handleMouseDown);
return () => label.removeEventListener('mousedown', handleMouseDown);
}
}, [labelRef]);

React.useEffect(() => {
if (htmlFor) {
const element = document.getElementById(htmlFor);
const label = labelRef.current;

if (label && element) {
const removeLabelClickEventListener = addLabelClickEventListener(label, element);
const getAriaLabel = () => element.getAttribute('aria-labelledby');
const ariaLabelledBy = [getAriaLabel(), id].filter(Boolean).join(' ');
element.setAttribute('aria-labelledby', ariaLabelledBy);

return () => {
removeLabelClickEventListener();
/**
* We get the latest attribute value because at the time that this cleanup fires,
* the values from the closure may have changed.
*/
const ariaLabelledBy = getAriaLabel()?.replace(id, '');
if (ariaLabelledBy === '') {
element.removeAttribute('aria-labelledby');
} else if (ariaLabelledBy) {
element.setAttribute('aria-labelledby', ariaLabelledBy);
}
};
}
}
}, [id, htmlFor]);

return (
<Comp {...labelProps} {...interopDataAttrObj('root')} id={id} ref={ref}>
<LabelContext.Provider value={id}>{children}</LabelContext.Provider>
<Comp {...labelProps} {...interopDataAttrObj('root')} id={id} ref={ref} role="label">
<LabelContext.Provider value={React.useMemo(() => ({ id, ref: labelRef }), [id])}>
{children}
</LabelContext.Provider>
</Comp>
);
});

function addLabelClickEventListener(label: HTMLSpanElement, element: HTMLElement) {
const handleClick = (event: MouseEvent) => {
/**
* When a label is wrapped around the element it labels, we make sure we manually trigger
* the element events only when clicking the label and not when clicking the element
* inside it.
*/
if (!element.contains(event.target as Node)) {
element.click();
element.focus();
}
};

label.addEventListener('click', handleClick);
return () => label.removeEventListener('click', handleClick);
}

/* ---------------------------------------------------------------------------------------------- */

Label.displayName = LABEL_NAME;
Expand All @@ -47,6 +116,7 @@ const [styles, interopDataAttrObj] = createStyleObj(LABEL_NAME, {
// allow vertical margins
display: 'inline-block',
verticalAlign: 'middle',
cursor: 'default',
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# `react-radio`
# `react-radio-group`

## Installation

```sh
$ yarn add @interop-ui/react-radio
$ yarn add @interop-ui/react-radio-group
# or
$ npm install @interop-ui/react-radio
$ npm install @interop-ui/react-radio-group
```

## Usage
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@interop-ui/react-radio",
"version": "0.0.1-5",
"name": "@interop-ui/react-radio-group",
"version": "0.0.0",
"license": "MIT",
"source": "src/index.ts",
"main": "dist/index.js",
Expand All @@ -12,12 +12,13 @@
],
"scripts": {
"build": "parcel build src/index.ts --no-cache && yarn build:styles",
"build:styles": "node ../../../scripts/build-styles radio",
"build:styles": "node ../../../scripts/build-styles radio-group",
"clean": "rm -rf dist",
"version": "yarn version",
"prepublish": "yarn clean && yarn build"
},
"dependencies": {
"@interop-ui/react-label": "workspace:*",
"@interop-ui/react-utils": "workspace:*",
"@interop-ui/utils": "workspace:*"
},
Expand Down
Loading