Skip to content

fix: ensure isReadonly applies to all non-literal date segments #7969

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

Persists
Copy link
Contributor

Found During Testing

When isReadonly is set, all segments should be non-editable.

For RAC components, this ensures that when isReadonly is applied to DateField or TimeField, the non-literal DateSegments have isReadonly=true and isEditable=false

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@Persists Persists changed the title fix: force data-readonly when datefield is readonly Ensure isReadonly applies to all non-literal DateSegments Mar 20, 2025
@Persists Persists changed the title Ensure isReadonly applies to all non-literal DateSegments fix: ensure isReadonly applies to all non-literal DateSegments Mar 20, 2025
@Persists Persists changed the title fix: ensure isReadonly applies to all non-literal DateSegments fix: ensure isReadonly applies to all non-literal date segments Mar 20, 2025
@Persists Persists marked this pull request as draft March 20, 2025 14:46
@Persists Persists force-pushed the fix-readonly-datesegment branch from 08e945b to 6a6319c Compare March 20, 2025 14:47
@Persists Persists force-pushed the fix-readonly-datesegment branch from 6a6319c to 3ed43ed Compare March 20, 2025 14:48
@Persists Persists marked this pull request as ready for review March 20, 2025 14:48
@snowystinger
Copy link
Member

@nwidynski
Copy link
Contributor

nwidynski commented Mar 20, 2025

@snowystinger Here is the reproduction: https://codesandbox.io/p/devbox/floral-wind-2fx6pc?workspaceId=ws_nU6d6H4r59ATf4jbZ5rYc

You can see that only the literal elements carry the data-readonly attribute (red background). This is because isEditable in the state does not take isReadOnly into consideration, as we do in the aria layer:

let isEditable = !state.isDisabled && !state.isReadOnly && segment.isEditable;

This causes a mismatch between the data-* props and the aria props, e.g. contenteditable. We can either take isReadOnly into consideration while building the state or possibly just adjust the RAC layer.

To be honest i feel like both isDisabled and isReadOnly should be considered right in the state, but that probably has downstream implications.

@snowystinger
Copy link
Member

I'm not sure why the literals have readonly at all, the control is disabled, and the literals aren't interactive to being with. In aria, disabled almost the same as not existing. So it seems to me that readonly is unnecessary and shouldn't even be appearing there unless the control has isReadOnly
Also, those are literals, they are readonly to start with and the data-attribute won't be read.

Maybe we should back up, what is the use case you're trying to build?

@nwidynski
Copy link
Contributor

nwidynski commented Mar 21, 2025

@snowystinger No use case besides styling being difficult right now for isReadOnly, as its neither applied to all segments nor on the parent field. Our component test suite prompted us with this issue.

@LFDanLu
Copy link
Member

LFDanLu commented Mar 21, 2025

@nwidynski Just to clarify, the expectation here would be that data-readonly would only appear on the segments when isReadOnly is set on the DateField but NOT when isDisabled is applied correct?

@nwidynski
Copy link
Contributor

nwidynski commented Mar 21, 2025

@LFDanLu Yep. We had expected data-readonly to appear on ALL segments when isReadOnly is set on the field.

@nwidynski
Copy link
Contributor

@LFDanLu Anything else blocking here?

@snowystinger
Copy link
Member

snowystinger commented May 19, 2025

The goal here is still a little confusing to me.
Here's an outline of some tests to explain what I thought this was trying to do. This fails on both main and in this branch.


  it('provides slots', () => {
    let {getByRole, getAllByRole} = render(
      <DateField data-foo="bar">
        <Label>Birth date</Label>
        <DateInput data-bar="foo">
          {segment => <DateSegment segment={segment} data-test="test" />}
        </DateInput>
        <Text slot="description">Description</Text>
        <Text slot="errorMessage">Error</Text>
      </DateField>
    );

    let input = getByRole('group');
    expect(input).toHaveTextContent('mm/dd/yyyy');
    expect(input).toHaveAttribute('class', 'react-aria-DateInput');
    expect(input).toHaveAttribute('data-bar', 'foo');

    expect(input.closest('.react-aria-DateField')).toHaveAttribute('data-foo', 'bar');

    expect(input).toHaveAttribute('aria-labelledby');
    let label = document.getElementById(input.getAttribute('aria-labelledby'));
    expect(label).toHaveAttribute('class', 'react-aria-Label');
    expect(label).toHaveTextContent('Birth date');

    expect(input).toHaveAttribute('aria-describedby');
    expect(input.getAttribute('aria-describedby').split(' ').map(id => document.getElementById(id).textContent).join(' ')).toBe('Description Error');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('class', 'react-aria-DateSegment');
      expect(segment).toHaveAttribute('data-placeholder', 'true');
      expect(segment).toHaveAttribute('data-type');
      expect(segment).toHaveAttribute('data-test', 'test');
      expect(segment).not.toHaveAttribute('data-readonly');
    }

    for (let literal of [...input.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
    }
  });

  it('should support readonly state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isReadOnly>
        <Label>Birth date</Label>
        <DateInput>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );

    let group = getByRole('group');
    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('data-readonly');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
    }
  });

  it('should support readonly with disabled state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isReadOnly isDisabled>
        <Label>Birth date</Label>
        <DateInput>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );

    let group = getByRole('group');
    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).not.toHaveAttribute('data-readonly');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
    }
  });

  it('should support disabled state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isDisabled>
        <Label>Birth date</Label>
        <DateInput className={({isDisabled}) => isDisabled ? 'disabled' : ''}>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );
    let group = getByRole('group');
    expect(group).toHaveAttribute('data-disabled');
    expect(group).toHaveClass('disabled');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).not.toHaveAttribute('data-readonly');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
    }
  });

@nwidynski
Copy link
Contributor

@snowystinger The problem with this test suite is that there is no data-type="segment", instead only year, month, etc. This causes the data-readonly attribute not to be enforced on segments when isReadOnly is passed. This PR fixes what would fail if the test ran properly.

export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName';

it('should support readonly state', () => {
  let {getByRole} = render(
    <DateField isReadOnly>
      <Label>Birth date</Label>
      <DateInput>
        {segment => <DateSegment segment={segment} />}
      </DateInput>
    </DateField>
  );

  let group = getByRole('group');
  for (let segment of [...group.children].filter(child => child.getAttribute('data-type') === 'segment')) {
    expect(segment).toHaveAttribute('data-readonly');
  }
  for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
    expect(literal).toHaveAttribute('data-readonly');
  }
});

@snowystinger
Copy link
Member

doh, copy pasta strikes again, updated the tests to reflect that

@nwidynski
Copy link
Contributor

@snowystinger Does this suite pass on main?

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('data-readonly');
    }

This should fail in should support readonly state.

@snowystinger
Copy link
Member

It's failing both on main and in this branch

@nwidynski
Copy link
Contributor

nwidynski commented May 19, 2025

@snowystinger Ah, I see where the misunderstanding comes from. Our team is currently wondering why data-readonly is even set in the first place, when not explicitly using isReadOnly. In our opinion, this attribute should only be present when isReadOnly is set and should apply to all segment types equally if so. Since data-* attributes are primarily for styling, we don't see a reason why isDisabled for example should warrant a data-readonly, when it could be styled using data-disabled as usual.

This can easily lead to subtle inconsistencies in the design system if styling with opacity for example and isn't aligned with the behavior of other RACs. If we can find a common ground here, I can wrap up the implementation. Our expectation looks like this:

  it('provides slots', () => {
    let {getByRole, getAllByRole} = render(
      <DateField data-foo="bar">
        <Label>Birth date</Label>
        <DateInput data-bar="foo">
          {segment => <DateSegment segment={segment} data-test="test" />}
        </DateInput>
        <Text slot="description">Description</Text>
        <Text slot="errorMessage">Error</Text>
      </DateField>
    );

    let input = getByRole('group');
    expect(input).toHaveTextContent('mm/dd/yyyy');
    expect(input).toHaveAttribute('class', 'react-aria-DateInput');
    expect(input).toHaveAttribute('data-bar', 'foo');

    expect(input.closest('.react-aria-DateField')).toHaveAttribute('data-foo', 'bar');

    expect(input).toHaveAttribute('aria-labelledby');
    let label = document.getElementById(input.getAttribute('aria-labelledby'));
    expect(label).toHaveAttribute('class', 'react-aria-Label');
    expect(label).toHaveTextContent('Birth date');

    expect(input).toHaveAttribute('aria-describedby');
    expect(input.getAttribute('aria-describedby').split(' ').map(id => document.getElementById(id).textContent).join(' ')).toBe('Description Error');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('class', 'react-aria-DateSegment');
      expect(segment).toHaveAttribute('data-placeholder', 'true');
      expect(segment).toHaveAttribute('data-type');
      expect(segment).toHaveAttribute('data-test', 'test');
      expect(segment).not.toHaveAttribute('data-readonly');
    }

    for (let literal of [...input.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).not.toHaveAttribute('data-readonly');
    }
  });

  it('should support readonly state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isReadOnly>
        <Label>Birth date</Label>
        <DateInput>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );

    let group = getByRole('group');
    expect(group).toHaveAttribute('data-readonly');
    expect(group).not.toHaveAttribute('data-disabled');
    expect(group).not.toHaveClass('disabled');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('data-readonly');
      expect(segment).not.toHaveAttribute('data-disabled');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
      expect(literal).not.toHaveAttribute('data-disabled');
    }
  });

  it('should support readonly with disabled state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isReadOnly isDisabled>
        <Label>Birth date</Label>
        <DateInput>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );

    let group = getByRole('group');
    expect(group).toHaveAttribute('data-readonly');
    expect(group).toHaveAttribute('data-disabled');
    expect(group).toHaveClass('disabled');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).toHaveAttribute('data-readonly');
      expect(segment).toHaveAttribute('data-disabled');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).toHaveAttribute('data-readonly');
      expect(segment).toHaveAttribute('data-disabled');
    }
  });

  it('should support disabled state', () => {
    let {getByRole, getAllByRole} = render(
      <DateField isDisabled>
        <Label>Birth date</Label>
        <DateInput className={({isDisabled}) => isDisabled ? 'disabled' : ''}>
          {segment => <DateSegment segment={segment} />}
        </DateInput>
      </DateField>
    );
    let group = getByRole('group');
    expect(group).not.toHaveAttribute('data-readonly');
    expect(group).toHaveAttribute('data-disabled');
    expect(group).toHaveClass('disabled');

    for (let segment of getAllByRole('spinbutton')) {
      expect(segment).not.toHaveAttribute('data-readonly');
      expect(segment).toHaveAttribute('data-disabled');
    }
    for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) {
      expect(literal).not.toHaveAttribute('data-readonly');
      expect(segment).toHaveAttribute('data-disabled');
    }
  });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants