Skip to content
Open
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
35 changes: 35 additions & 0 deletions resources/js/components/Reveal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { nextTick, onMounted, onBeforeUnmount } from 'vue';

const registry = new WeakMap();

const FOCUSABLE_CONTROL_SELECTORS = [
'input:not([disabled]):not([type="hidden"]):not([aria-disabled="true"])',
'textarea:not([disabled]):not([aria-disabled="true"])',
'select:not([disabled]):not([aria-disabled="true"])',
'[role="switch"]:not([disabled]):not([aria-disabled="true"]):not([data-disabled])',
'[role="combobox"]:not([disabled]):not([aria-disabled="true"]):not([data-disabled])',
'[role="slider"]:not([disabled]):not([aria-disabled="true"])',
'button[data-ui-control]:not([disabled])',
];

class Reveal {
use(ref, callback) {
onMounted(() => this.mount(ref.value, callback));
Expand Down Expand Up @@ -29,11 +39,36 @@ class Reveal {
});
}

focusableControl(root) {
if (!root) return null;

for (const selector of FOCUSABLE_CONTROL_SELECTORS) {
const control = root.querySelector(selector);
if (!control) continue;
if (control.tabIndex < 0 && control.getAttribute('role') === 'combobox') continue;
return control;
}

return null;
}

invalid() {
nextTick(() => {
const el = document.querySelector('[data-ui-field-has-errors]:not(:has([data-ui-field-has-errors]))');
if (!el) return;

const fieldId = el.id || el.querySelector('[data-ui-label]')?.htmlFor || null;
this.element(el);

nextTick(() => {
const current =
document.querySelector('[data-ui-field-has-errors]:not(:has([data-ui-field-has-errors]))') || el;
const control =
this.focusableControl(current) ||
(fieldId ? document.getElementById(fieldId) : null);

control?.focus?.({ preventScroll: true });
});
});
}
}
Expand Down
15 changes: 12 additions & 3 deletions resources/js/components/ui/Combobox/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import {
ComboboxVirtualizer,
FocusScope
} from 'reka-ui';
import { computed, nextTick, ref, useAttrs, useTemplateRef, watch } from 'vue';
import { computed, nextTick, ref, toRef, useAttrs, useTemplateRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import Button from '../Button/Button.vue';
import Icon from '../Icon/Icon.vue';
import Badge from '../Badge.vue';
import fuzzysort from 'fuzzysort';
import DOMPurify from 'dompurify';
import { SortableList } from '@/components/sortable/Sortable.js';
import { useUiFieldId } from '@/composables/ui-field-id.js';

const emit = defineEmits(['update:modelValue', 'search', 'selected', 'added']);

Expand All @@ -37,8 +38,8 @@ const props = defineProps({
discreteFocusOutline: { type: Boolean, default: false },
/** Icon name. [Browse available icons](/?path=/story/components-icon--all-icons) */
icon: { type: String, default: null },
/** ID attribute for the input element */
id: { type: String },
/** ID attribute for the input element. Inherits from parent `Field` when omitted. */
id: { type: String, default: null },
/** When `true`, the Combobox will avoid filtering options, allowing you to handle filtering yourself by listening to the `search` event and updating the `options` prop. */
ignoreFilter: { type: Boolean, default: false },
/** When `true`, the option labels will be rendered with `v-html` instead of `v-text`. */
Expand Down Expand Up @@ -79,6 +80,8 @@ defineOptions({
inheritAttrs: false,
});

const { id, labelId, describedBy, invalid } = useUiFieldId(toRef(props, 'id'));

const attrs = useAttrs();

const wrapperClasses = computed(() => twMerge('w-full min-w-0', attrs.class));
Expand Down Expand Up @@ -397,6 +400,10 @@ defineExpose({
as="div"
ref="trigger"
v-bind="triggerAttrs"
:id="shouldShowInput ? undefined : id"
:aria-labelledby="shouldShowInput ? undefined : labelId"
:aria-describedby="shouldShowInput ? undefined : describedBy"
:aria-invalid="!shouldShowInput && invalid ? 'true' : undefined"
:class="triggerClasses"
data-ui-combobox-trigger
@keydown.enter="openDropdown"
Expand All @@ -407,6 +414,8 @@ defineExpose({
v-if="shouldShowInput"
:id
:placeholder
:aria-describedby="describedBy"
:aria-invalid="invalid ? 'true' : undefined"
ref="search"
class="w-full bg-transparent text-gray-900 dark:text-gray-300 opacity-100 focus:outline-none placeholder-gray-500 dark:placeholder-gray-400 [&::-webkit-search-cancel-button]:hidden cursor-pointer"
:class="{
Expand Down
8 changes: 7 additions & 1 deletion resources/js/components/ui/Description.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ const slots = useSlots();
const hasDefaultSlot = !!slots.default;

const props = defineProps({
/** Optional id so the description can be referenced by aria-describedby */
id: { type: String, default: null },
/** The description text to display */
text: { type: [String, Number, Boolean, null], default: null },
});
</script>

<template>
<div class="text-sm font-normal text-gray-600/90 dark:text-gray-400 st-text-trim-start [&_a]:underline [&_a:hover]:text-gray-700 dark:[&_a:hover]:text-gray-300 [&_code]:px-0.5 [&_code]:rounded-sm" data-ui-description>
<div
:id="id"
class="text-sm font-normal text-gray-600/90 dark:text-gray-400 st-text-trim-start [&_a]:underline [&_a:hover]:text-gray-700 dark:[&_a:hover]:text-gray-300 [&_code]:px-0.5 [&_code]:rounded-sm"
data-ui-description
>
<slot v-if="hasDefaultSlot" />
<span v-else v-html="text" />
</div>
Expand Down
8 changes: 7 additions & 1 deletion resources/js/components/ui/ErrorMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ const slots = useSlots();
const hasDefaultSlot = !!slots.default;

const props = defineProps({
/** Optional id so the error can be referenced by aria-describedby */
id: { type: String, default: null },
/** The error message to display */
text: { type: [String, Number, Boolean, null], default: null },
});
</script>

<template>
<div class="text-sm font-normal text-red-600 st-text-trim-start text-pretty @5xl/panel:text-balance [[data-ui-panel-header]_&]:text-red-600 [&_a]:underline [&_a:hover]:text-red-700 dark:[&_a:hover]:text-red-300" data-ui-error-message>
<div
:id="id"
class="text-sm font-normal text-red-600 st-text-trim-start text-pretty @5xl/panel:text-balance [[data-ui-panel-header]_&]:text-red-600 [&_a]:underline [&_a:hover]:text-red-700 dark:[&_a:hover]:text-red-300"
data-ui-error-message
>
<slot v-if="hasDefaultSlot" />
<span v-else v-html="text" />
</div>
Expand Down
95 changes: 73 additions & 22 deletions resources/js/components/ui/Field.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup>
import { cva } from 'cva';
import { computed } from 'vue';
import { computed, provide, useId } from 'vue';
import Description from './Description.vue';
import Label from './Label.vue';
import ErrorMessage from './ErrorMessage.vue';
import markdown from '@/util/markdown.js';
import { twMerge } from 'tailwind-merge';
import { UI_FIELD_ID_KEY } from '@/composables/ui-field-id.js';

defineOptions({
inheritAttrs: false,
Expand All @@ -25,7 +26,8 @@ const props = defineProps({
errors: { type: Object },
/** When `true`, forces the field to use full width even when `asConfig` is enabled. */
fullWidthSetting: { type: Boolean, default: false },
id: { type: String },
/** ID shared by the label and control. Defaults to a generated id. */
id: { type: String, default: null },
/** Instructions text to display above or below the label. Supports Markdown. */
instructions: { type: String, default: '' },
/** When `true`, displays instructions below the control instead of below the label. */
Expand All @@ -36,9 +38,60 @@ const props = defineProps({
required: { type: Boolean, default: false },
});

const generatedId = useId();
const hasExplicitId = computed(() => props.id != null && props.id !== '');
const fieldId = computed(() => (hasExplicitId.value ? props.id : generatedId));
const labelId = computed(() => `${fieldId.value}-label`);
const descriptionId = computed(() => `${fieldId.value}-description`);

const instructions = computed(() => props.instructions ? markdown(__(props.instructions), { openLinksInNewTabs: true }) : null);

const errorList = computed(() => {
if (props.error) {
return [props.error];
}

if (!props.errors) {
return [];
}

return Array.isArray(props.errors) ? props.errors : Object.values(props.errors);
});

const hasErrors = computed(() => errorList.value.length > 0);

// Keep the first error id stable as `${fieldId}-error` for the common single-error case.
const errorIds = computed(() =>
errorList.value.map((_, i) => (i === 0 ? `${fieldId.value}-error` : `${fieldId.value}-error-${i}`)),
);

const describedBy = computed(() => {
const ids = [];

if (instructions.value) {
ids.push(descriptionId.value);
}

ids.push(...errorIds.value);

return ids.length ? ids.join(' ') : null;
});

// Auto-generated ids may be claimed by the first nested control. Explicit ids (Publish)
// stay on the fieldtype via its own `id` prop. describedBy/invalid are always available.
provide(UI_FIELD_ID_KEY, {
id: fieldId,
labelId,
claimable: !hasExplicitId.value,
describedBy,
invalid: hasErrors,
claimed: false,
});

const labelProps = computed(() => ({
badge: props.badge,
for: props.id,
for: fieldId.value,
id: labelId.value,
required: props.required,
text: props.label,
}));
Expand All @@ -64,21 +117,6 @@ const rootClasses = computed(() =>
...props,
})),
);

const instructions = computed(() => props.instructions ? markdown(__(props.instructions), { openLinksInNewTabs: true }) : null);

const errors = computed(() => {
if (props.error) {
return [props.error];
}

return props.errors;
});

const hasErrors = computed(() => {
if (!errors.value) return false;
return Array.isArray(errors.value) ? errors.value.length > 0 : Object.keys(errors.value).length > 0;
});
</script>

<template>
Expand All @@ -101,12 +139,25 @@ const hasErrors = computed(() => {
<slot v-else name="label">
<Label v-if="label" v-bind="labelProps" />
</slot>
<Description :text="instructions" v-if="instructions && !instructionsBelow" />
<Description
v-if="instructions && !instructionsBelow"
:id="descriptionId"
:text="instructions"
/>
</div>
<slot />
<slot :id="fieldId" />
<div v-if="(instructions && instructionsBelow) || hasErrors" class="flex flex-col gap-2">
<Description :text="instructions" v-if="instructions && instructionsBelow" />
<ErrorMessage v-if="errors" v-for="(error, i) in errors" :key="i" :text="error" />
<Description
v-if="instructions && instructionsBelow"
:id="descriptionId"
:text="instructions"
/>
<ErrorMessage
v-for="(error, i) in errorList"
:id="errorIds[i]"
:key="i"
:text="error"
/>
</div>
</div>
</template>
26 changes: 21 additions & 5 deletions resources/js/components/ui/Input/Input.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup>
import { computed, useSlots, useAttrs, ref, useId, useTemplateRef, onMounted, nextTick } from 'vue';
import { computed, useSlots, useAttrs, ref, toRef, useTemplateRef, onMounted, nextTick } from 'vue';
import { cva } from 'cva';
import { twMerge } from 'tailwind-merge';
import Icon from '../Icon/Icon.vue';
import Button from '../Button/Button.vue';
import CharacterCounter from '../CharacterCounter.vue';
import useCopy from '@/composables/copy';
import { mergeAriaDescribedBy, useUiFieldId } from '@/composables/ui-field-id.js';

defineOptions({ inheritAttrs: false });

Expand All @@ -29,8 +30,8 @@ const props = defineProps({
iconAppend: { type: String, default: null },
/** Icon name. Will display before the text. [Browse available icons](/?path=/story/components-icon--all-icons) */
iconPrepend: { type: String, default: null },
/** ID attribute for the input element */
id: { type: String, default: () => useId() },
/** ID attribute for the input element. Inherits from parent `Field` when omitted. */
id: { type: String, default: null },
/** Specify a character limit */
limit: { type: Number, default: null },
/** When `true`, an animated loading indicator will show next to the input */
Expand All @@ -57,6 +58,8 @@ const props = defineProps({
inputClass: { type: String, default: '' },
});

const { id, describedBy, invalid } = useUiFieldId(toRef(props, 'id'));

const inputAttributeKeys = [
'accept', 'autocomplete', 'autofocus', 'capture', 'checked', 'dirname', 'form',
'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget',
Expand All @@ -67,7 +70,9 @@ const inputAttributeKeys = [
const outerAttrs = computed(() => {
const result = {};
for (const key in attrs) {
if (!inputAttributeKeys.includes(key.toLowerCase())) result[key] = attrs[key];
if (!inputAttributeKeys.includes(key.toLowerCase()) && key !== 'aria-describedby' && key !== 'aria-invalid') {
result[key] = attrs[key];
}
}
return result;
});
Expand All @@ -90,6 +95,15 @@ const inputAttrs = computed(() => {
return { ...result, ...normalizedInputAttrs.value };
});

const controlAttrs = computed(() => {
const { 'aria-describedby': _describedBy, 'aria-invalid': _invalid, ...rest } = inputAttrs.value;
return rest;
});

const resolvedDescribedBy = computed(() =>
mergeAriaDescribedBy(describedBy.value, inputAttrs.value['aria-describedby'], attrs['aria-describedby']),
);

const hasPrependedIcon = computed(() => !!props.iconPrepend || !!props.icon || !!slots.prepend);
const hasAppendedIcon = computed(() => !!props.iconAppend || !!slots.append || clearable.value || props.viewable || canCopy.value || props.loading);

Expand Down Expand Up @@ -227,7 +241,9 @@ defineExpose({ focus, select });
:readonly="readOnly"
data-ui-control
data-ui-group-target
v-bind="inputAttrs"
v-bind="controlAttrs"
:aria-describedby="resolvedDescribedBy"
:aria-invalid="invalid ? 'true' : undefined"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div v-if="hasAppendedIcon" :class="appendedIconClasses">
Expand Down
3 changes: 3 additions & 0 deletions resources/js/components/ui/Label.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const hasDefaultSlot = !!slots.default;
const props = defineProps({
/** The ID of the form element this label is for */
for: { type: String, default: null },
/** Optional id attribute for the label element itself */
id: { type: String, default: null },
/** Optional badge text to display on the right side of the label */
badge: { type: String, default: '' },
required: { type: Boolean, default: false },
Expand All @@ -20,6 +22,7 @@ const props = defineProps({
<label
class="flex justify-between text-sm font-medium [&_button]:font-medium text-gray-925 select-none dark:text-gray-300 [&_button:has(svg)]:h-auto"
data-ui-label
:id="id"
:for="for"
>
<div>
Expand Down
Loading
Loading